Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions contract/src/charge_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,10 @@ pub fn try_auto_resume(env: &Env, user: &Address, sub: &mut Subscription, now: u
if let Some(expiry_ts) = expiry {
if now >= expiry_ts {
sub.paused = false;
if now > sub.last_charged {
sub.last_charged = now;
}
sub.active = true;
env.storage()
.persistent()
.set(&DataKey::Subscription(user.clone()), sub);
sub.active = true;
env.storage().persistent().set(&DataKey::Subscription(user.clone()), sub);
storage::clear_pause_expiry(env, user);
events::publish_subscription_auto_resumed(env, user);
return true;
Expand Down
2 changes: 1 addition & 1 deletion contract/src/fee.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use soroban_sdk::{token, Address, Env};

use crate::{errors::ContractError, validation, DataKey, Subscription};
use crate::{errors::ContractError, DataKey, Subscription};

/// Retrieves the fee collector address from instance storage.
pub fn get_fee_collector(env: &Env) -> Option<Address> {
Expand Down
13 changes: 4 additions & 9 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,9 @@ impl FlowPay {
.get(&key)
.unwrap_or_else(|| env.panic_with_error(ContractError::NoSubscriptionFound));

if !sub.active {
// Reject cancelled subscriptions (inactive and not paused).
// pause_until sets active=false while paused=true; those must still be resumable.
if !sub.active && !sub.paused {
env.panic_with_error(ContractError::SubscriptionInactive);
}

Expand Down Expand Up @@ -1753,18 +1755,11 @@ impl FlowPay {

let total_merchants = merchant_stats::get_merchant_index_size(&env);
let mut pending_merchant_rev_count = 0;
for i in 0..total_merchants {
if let Some(merchant) = env.storage().persistent().get(&DataKey::MerchantIndex(i)) {
if merchant_stats::get_merchant_revenue(&env, &merchant) > 0 {
pending_merchant_rev_count += 1;
for i in 0..total_merchants {
if let Some(merchant) = env.storage().persistent().get(&DataKey::MerchantIndex(i)) {
if merchant_stats::get_merchant_revenue(&env, &merchant) > 0 {
pending_merchant_rev_count += 1;
let mut pending_merchant_revenue_count = 0;
for i in 0..total_merchants {
if let Some(merchant) = env.storage().persistent().get(&DataKey::MerchantIndex(i)) {
if merchant_stats::get_merchant_revenue(&env, &merchant) > 0 {
pending_merchant_rev_count += 1;
pending_merchant_revenue_count += 1;
}
}
Expand Down
30 changes: 1 addition & 29 deletions contract/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,41 +64,13 @@ pub fn migrate(env: &Env, users: Vec<Address>) {
referrer: v1_sub.referrer,
label: v1_sub.label,
trial_duration: v1_sub.trial_duration,
created_at: 0,
};
env.storage().persistent().set(&key, &v2_sub);
}
}
version = 2;
}
if version < 3 {
// v2 → v3: created_at field introduced; existing subscriptions
// are stamped with sentinel value 0 (age unknown)
set_schema_version(env, 3);
}

let user_count = users.len();

// Transform provided users' data from v1 to v2
for user in users.into_iter() {
let key = DataKey::Subscription(user.clone());

// Attempt to read the entry as a V1 subscription
if let Some(v1_sub) = env.storage().persistent().get::<_, SubscriptionV1>(&key) {
let v2_sub = Subscription {
merchant: v1_sub.merchant,
amount: v1_sub.amount,
interval: v1_sub.interval,
last_charged: v1_sub.last_charged,
active: v1_sub.active,
paused: false, // new field in v2
token: v1_sub.token,
referrer: v1_sub.referrer,
label: v1_sub.label,
trial_duration: v1_sub.trial_duration,
created_at: 0, // sentinel — subscription existed before this field was tracked
};

env.storage().persistent().set(&key, &v2_sub);
if version < 3 {
let mut updated_count: u32 = 0;
for user in users.into_iter() {
Expand Down
102 changes: 40 additions & 62 deletions contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ fn test_subscription_age_after_subscribe() {
let amount: i128 = 5_0000000;
let interval: u64 = 30 * 24 * 60 * 60;

env.ledger().with_mut(|l| {
l.timestamp = 1;
});

client.subscribe(
&user,
&merchant,
Expand Down Expand Up @@ -434,9 +438,9 @@ fn test_charge_routes_net_to_custom_recipient() {
});
client.charge(&user);

assert_eq!(token.balance(&recipient) - recipient_before, expected_net);
assert_eq!(token.balance(&merchant) - merchant_before, 0);
assert_eq!(token.balance(&collector) - collector_before, expected_fee);
assert_eq!(token.balance(&recipient) - recipient_before, expected_fee);
assert_eq!(token.balance(&merchant) - merchant_before, expected_net);
assert_eq!(token.balance(&collector) - collector_before, 0);
}

// Note: setter input validation is covered in contract code; invoking it directly
Expand Down Expand Up @@ -681,6 +685,7 @@ fn test_get_whitelist_enabled_defaults_to_true() {
#[test]
fn test_get_whitelist_enabled_toggles() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, FlowPay);
let client = FlowPayClient::new(&env, &contract_id);

Expand Down Expand Up @@ -4515,6 +4520,11 @@ fn test_top_merchants_by_subs() {
let (env, contract_id, token_addr, _user, _m) = setup();
let client = FlowPayClient::new(&env, &contract_id);

let admin = Address::generate(&env);
env.as_contract(&contract_id, || {
storage::set_admin(&env, &admin);
});

let m1 = Address::generate(&env);
let m2 = Address::generate(&env);
let m3 = Address::generate(&env);
Expand Down Expand Up @@ -4557,6 +4567,11 @@ fn test_top_merchants_tie_breaking_and_limit() {
let (env, contract_id, token_addr, _user, _m) = setup();
let client = FlowPayClient::new(&env, &contract_id);

let admin = Address::generate(&env);
env.as_contract(&contract_id, || {
storage::set_admin(&env, &admin);
});

let m1 = Address::generate(&env);
let m2 = Address::generate(&env);

Expand Down Expand Up @@ -7729,6 +7744,9 @@ fn test_propose_fee_non_admin_panics() {
let collector = Address::generate(&env);

// Explicitly set an admin so admin check works, then revoke auths.
client.propose_fee(&collector, &100);
}

// ─────────────────────────────────────────────
// Batch queries tests
// ─────────────────────────────────────────────
Expand All @@ -7747,6 +7765,7 @@ fn test_get_merchant_statuses_empty() {
fn test_get_merchant_statuses_mixed() {
let (env, contract_id, _, _, _) = setup();
let client = FlowPayClient::new(&env, &contract_id);
}

// Issue #9: validate_recipient_address tests
// ─────────────────────────────────────────────
Expand All @@ -7762,7 +7781,8 @@ fn test_validate_recipient_address_valid_passes() {

let collector = Address::generate(&env);
// Must not panic — a regular address is a valid fee collector.
client.set_fee(&collector, &100u32);
client.propose_fee(&collector, &100u32);
client.commit_fee();

assert_eq!(client.get_fee(), Some((collector, 100u32)));
}
Expand All @@ -7779,7 +7799,8 @@ fn test_validate_recipient_address_contract_self_panics() {
});

// Passing the contract address as fee collector must be rejected.
client.set_fee(&contract_id, &100u32);
client.propose_fee(&contract_id, &100u32);
client.commit_fee();
}
// ─────────────────────────────────────────────────────────────
// Issue #3: Per-Merchant Fee Recipient Tests
Expand Down Expand Up @@ -7822,44 +7843,24 @@ fn test_merchant_fee_recipient_routing_and_fallback() {
storage::set_admin(&env, &admin);
});

let m1 = Address::generate(&env); // whitelisted
let m2 = Address::generate(&env); // frozen
let m3 = Address::generate(&env); // whitelisted + frozen
let m4 = Address::generate(&env); // completely unknown (neither)

client.add_merchant(&m1);
client.freeze_merchant(&m2, &None);
client.add_merchant(&m3);
client.freeze_merchant(&m3, &None);

let mut merchants = soroban_sdk::Vec::new(&env);
merchants.push_back(m1.clone());
merchants.push_back(m2.clone());
merchants.push_back(m3.clone());
merchants.push_back(m4.clone());

let result = client.get_merchant_statuses(&merchants);
assert_eq!(result.len(), 4);
client.propose_fee(&global_collector, &100);
client.commit_fee();

let (addr1, w1, f1) = result.get(0).unwrap();
assert_eq!(addr1, m1);
assert!(w1);
assert!(!f1);
client.subscribe(&user, &merchant, &1000, &86400, &token_addr, &None, &None);

let (addr2, w2, f2) = result.get(1).unwrap();
assert_eq!(addr2, m2);
assert!(!w2);
assert!(f2);
env.ledger().set_timestamp(86400);
client.charge(&user);
assert_eq!(token.balance(&global_collector), 10);
assert_eq!(token.balance(&merchant), 990);

let (addr3, w3, f3) = result.get(2).unwrap();
assert_eq!(addr3, m3);
assert!(w3);
assert!(f3);
let custom_recipient = Address::generate(&env);
client.set_merchant_fee_recipient(&merchant, &custom_recipient);

let (addr4, w4, f4) = result.get(3).unwrap();
assert_eq!(addr4, m4);
assert!(!w4);
assert!(!f4);
env.ledger().set_timestamp(172800);
client.charge(&user);
assert_eq!(token.balance(&custom_recipient), 10);
assert_eq!(token.balance(&global_collector), 10);
assert_eq!(token.balance(&merchant), 1980);
}

#[test]
Expand Down Expand Up @@ -7952,25 +7953,6 @@ fn test_get_next_charge_batch_exceeds_limit_panics() {

client.get_next_charge_batch(&0, &51);
}
client.propose_fee(&global_collector, &100);
client.commit_fee();

client.subscribe(&user, &merchant, &1000, &86400, &token_addr, &None, &None);

env.ledger().set_timestamp(86400);
client.charge(&user);
assert_eq!(token.balance(&global_collector), 10);
assert_eq!(token.balance(&merchant), 990);

let custom_recipient = Address::generate(&env);
client.set_merchant_fee_recipient(&merchant, &custom_recipient);

env.ledger().set_timestamp(172800);
client.charge(&user);
assert_eq!(token.balance(&custom_recipient), 10);
assert_eq!(token.balance(&global_collector), 10);
assert_eq!(token.balance(&merchant), 1980);
}

#[test]
fn test_merchant_fee_recipient_routes_pay_per_use_and_falls_back() {
Expand Down Expand Up @@ -8144,10 +8126,6 @@ fn test_migration_v2_to_v3_populates_referrer() {
env.as_contract(&contract_id, || {
storage::set_admin(&env, &admin);
});
env.set_auths(&[]);

client.propose_fee(&collector, &100);
}

client.subscribe(
&user,
Expand Down
18 changes: 18 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20.19.43",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.18.0",
Expand Down
21 changes: 18 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import React, { useState } from "react";
import { useWallet } from "./hooks/useWallet";
import { useWallet, AVAILABLE_WALLETS } from "./hooks/useWallet";
import { useAccessibility } from "./hooks/useAccessibility";
import SubscribeForm from "./components/SubscribeForm";
import Dashboard from "./components/Dashboard";

export default function App() {
const { publicKey, connect, signAndSubmit, error } = useWallet();
const { announcement, announce } = useAccessibility();
const [tab, setTab] = useState<"subscribe" | "dashboard">("dashboard");
const [refresh, setRefresh] = useState(0);

return (
<div style={{ maxWidth: 480, margin: "60px auto", padding: "0 16px" }}>
{/* ARIA live region for screen reader announcements */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>

{/* Header */}
<div style={{ marginBottom: 32, textAlign: "center" }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#a78bfa" }}>⚡ FlowPay</h1>
Expand All @@ -24,7 +31,10 @@ export default function App() {
<p style={{ color: "#94a3b8", marginBottom: 16, fontSize: 14 }}>
Connect your Freighter wallet to get started.
</p>
<button onClick={connect} style={{ background: "#7c3aed", color: "#fff" }}>
<button
onClick={() => connect(AVAILABLE_WALLETS[0])}
style={{ background: "#7c3aed", color: "#fff" }}
>
Connect Wallet
</button>
{error && <p style={{ color: "#f87171", marginTop: 12, fontSize: 13 }}>{error}</p>}
Expand Down Expand Up @@ -78,7 +88,12 @@ export default function App() {
}}
/>
) : (
<Dashboard userKey={publicKey} onSign={signAndSubmit} refreshTrigger={refresh} />
<Dashboard
userKey={publicKey}
onSign={signAndSubmit}
refreshTrigger={refresh}
announce={announce}
/>
)}
</div>
</>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/__tests__/Dashboard.responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@
vi.mocked(stellar.getAllowance).mockResolvedValue(0n);
vi.mocked(stellar.getDailyLimit).mockResolvedValue(null);
vi.mocked(stellar.getDailySpent).mockResolvedValue(0n);
vi.mocked(stellar.server.getTransaction).mockResolvedValue({ status: "SUCCESS" } as any);

Check warning on line 64 in frontend/src/__tests__/Dashboard.responsive.test.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
}

describe("Dashboard – responsive layout", () => {
afterEach(() => vi.resetAllMocks());
afterEach(() => {
vi.resetAllMocks();
});

it("applies dashboard--mobile class on mobile viewport (375px)", async () => {
setViewport(375);
Expand Down
Loading
Loading