icRamp Devlog #14 — Stripe Frontend (Register & Checkout UX)

icRamp Devlog #14 — Stripe Frontend (Register & Checkout UX)

10/19/202510 min • icramp
ICPStripeConnectCheckoutFrontendReact

Follows [Devlog #13 — Stripe Backend (Connect + Checkout)]: there we shipped multi-platform Stripe config, Express onboarding, and Checkout Sessions from the canister. This post is the UI + wiring: a resilient register flow that survives the Stripe redirect, cleaner provider cards, and a Create Order screen that uses destination charges. It also introduces the Onramper vs Offramper split.

TL;DR — What changed

  • Single hook to start onboarding: startStripeKyc(...). It stores temp user only when needed (register page), and redirects to Stripe.
  • After redirect back:
    • Register restores loginMethod, userType, providers from localStorage and appends the new Stripe provider from query (stripe_acct, platform).
    • Profile finalizes by calling add_user_payment_provider and then cleans the URL.
  • Backend validates providers (Stripe capabilities, email format) and enforces role rules:
    • Stripe only for Offrampers.
    • Email provider only for Onrampers.
  • Onramper vs Offramper:
    • Offramper links Stripe (connected account) and receives destination charges.
    • Onramper does not link Stripe; they provide an Email provider (payer identity) used later to verify the payer.
  • UI: New provider cards (selectable, readable), centered onboarding CTA with spinner on the right, success notice in green under the cards (not in the error slot).
  • Create Order: Provider list is now the same card style; selecting Stripe means “pay the offramper via destination charge”.

Onramper ≠ Offramper (data model)

We extended the backend (recap of the delta since #13) and modified the Stripe enum, also added a new one: Email.

#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
pub enum PaymentProviderType { PayPal, Revolut, Stripe, Email }
 
#[derive(CandidType, Deserialize, Clone, Debug, Eq)]
pub enum PaymentProvider {
  PayPal { id: String },
  Revolut { scheme: String, id: String, name: Option<String> },
  Stripe { account_id: String, platform: String }, // Offramper only
  Email  { email: String },                        // Onramper only
}
  • Offramper: can link Stripe{account_id, platform} (validated server-side).
  • Onramper: provides Email{email} to later verify that the payer matches.

Validation happens server-side:

impl PaymentProvider {
  pub async fn validate(&self) -> Result<()> {
    match self {
      PaymentProvider::Stripe { account_id, platform } => {
        // ensure "acct_" and platform exists, then hit Stripe to check
        verify_stripe_acct(account_id, platform).await?;
      }
      PaymentProvider::Email { email } => validate_email(email)?,
      // ... PayPal/Revolut sanity checks ...
    }
    Ok(())
  }
}

This makes the frontend simpler and safer: FE never needs Stripe secrets nor capability checks.

The Stripe KYC hook

We keep it minimal and typed. It picks the right actor, optionally persists temp user (register only), creates the Express account, builds the account-link, and redirects.

// hooks/useStripeKyc.ts
import { ActorSubclass } from '@dfinity/agent';
import {
  _SERVICE,
  LoginAddress,
  PaymentProvider,
} from '@/declarations/icramp_backend/icramp_backend.did';
import { backend } from '@/model/backendProxy';
import { storeTempUserData } from '@/model/emailConfirmation';
import { rampErrorToString } from '@/model/helpers/error';
import { mapCountryToPlatform } from '@/utils/stripe';
 
type StartArgs = {
  userType: 'Offramper' | 'Onramper' | 'Visitor';
  loginMethod: LoginAddress | null;
  backendActor?: ActorSubclass<_SERVICE> | null;
  email: string; // providerId
  country: string; // 'ES', 'US', ...
  providers?: PaymentProvider[]; // only needed in Register page (to store temp)
  password?: string; // only needed in Register page
  storeUserData: boolean;
  setMessage: (s: string) => void;
  setLoading: (b: boolean) => void;
};
 
export async function startStripeKyc({
  userType,
  loginMethod,
  backendActor,
  email,
  country,
  providers,
  password,
  storeUserData,
  setMessage,
  setLoading,
}: StartArgs) {
  setMessage('');
  if (userType !== 'Offramper') {
    setMessage('Stripe is only used for Offrampers.');
    return;
  }
  if (!loginMethod) {
    setMessage('Please login first.');
    return;
  }
  if (!email || !country) {
    setMessage('Email and country are required for Stripe onboarding.');
    return;
  }
 
  const actor =
    'ICP' in (loginMethod ?? {}) && backendActor ? backendActor : backend;
 
  // optional (Register): preserve temp state for post-redirect
  if (storeUserData) {
    storeTempUserData({
      providers: providers ?? [],
      userType,
      loginMethod,
      password: password ?? '',
      confirmationToken: '',
    });
  }
 
  setLoading(true);
  const platform = mapCountryToPlatform(country);
  try {
    const r1 = await actor.stripe_create_express_account(email, country, [
      platform,
    ]);
    if ('Err' in r1) {
      setMessage(`Stripe create account failed: ${rampErrorToString(r1.Err)}`);
      return;
    }
    const acct = r1.Ok;
 
    const origin = window.location.origin;
    const returnUrl = `${origin}${
      window.location.pathname
    }?stripe_acct=${encodeURIComponent(acct)}&platform=${encodeURIComponent(
      platform,
    )}`;
    const refreshUrl = `${origin}${
      window.location.pathname
    }?refresh=1&stripe_acct=${encodeURIComponent(
      acct,
    )}&platform=${encodeURIComponent(platform)}`;
 
    const r2 = await actor.stripe_create_account_link(
      acct,
      refreshUrl,
      returnUrl,
      [platform],
    );
    if ('Err' in r2) {
      setMessage(`Stripe account link failed: ${rampErrorToString(r2.Err)}`);
      return;
    }
 
    window.location.href = r2.Ok;
  } catch (e: any) {
    setMessage(`Stripe onboarding failed: ${e?.message ?? String(e)}`);
  } finally {
    setLoading(false);
  }
}

Where we introduce a minimal helper to map countries to platforms. We will expand this latter as we support more regions.

// utils/strip.ts
export function mapCountryToPlatform(country: string): 'US' | 'ES' {
  return (country || '').toUpperCase() === 'US' ? 'US' : 'ES';
}

Register — survive redirect, then submit

1) Persist “in-flight” state before leaving for Stripe

We store the whole context right before opening the onboarding URL:

// before redirecting to Stripe
storeTempUserData({
  providers,         // whatever the user added so far
  userType,          // Onramper | Offramper
  loginMethod,       // EVM | ICP | ... (from Connect Address)
  password: password ?? '',
  confirmationToken: '',
});

2) Start onboarding

const startStripeOnboarding = async () => {
    await startStripeKyc({
        userType,
        loginMethod,
        backendActor,
        email: providerId,
        country: stripeCountry,
        providers,
        password: password ?? undefined,
        storeUserData: true,
        setMessage,
        setLoading: setLoadingStripe,
    });
};

3) Restore after returning from Stripe

On the /register?stripe_acct=...&platform=... route we restore the data and append the connected account provider. If a mismatch is detected (different login), we clear the temp payload.

const restoreTempData = () => {
    const t = getTempUserData();
    const acct = searchParams.get('stripe_acct');
    if (t && acct) {
        if (t.providers?.length) setProviders(t.providers);
        if (t.userType) setUserType(t.userType);
        if (t.loginMethod) setLoginMethod(t.loginMethod, t.password ?? null);
        return true;
    } else if (t && t.loginMethod && loginMethod && t.loginMethod !== loginMethod) {
        clearTempUserData();
    }
    return false;
};
 
useEffect(() => {
    restoreTempData();
    const acct = searchParams.get('stripe_acct');
    const platform = searchParams.get('platform');
    if (!acct || !platform) return;
 
    (async () => {
        try {
            setProviders((prev) => {
                const next = prev.some((p) => 'Stripe' in p && p.Stripe.account_id === acct)
                    ? prev
                    : [...prev, { Stripe: { account_id: acct, platform: mapCountryToPlatform(stripeCountry) } }];
                const t = getTempUserData();
                if (t) storeTempUserData({ ...t, providers: next });
                return next;
            });
            setStripeReady(true);
            setStripeInfoMsg('Stripe account added.');
        } catch (e) {
            setMessage(`Failed to verify Stripe account: ${e}`);
        }
    })();
}, [searchParams]);

4) Provider type visibility (per role)

const visibleProviderTypes = useMemo<PaymentProviderTypes[]>(() =>
  userType === 'Offramper'
    ? ['PayPal', 'Revolut', 'Stripe']
    : ['PayPal', 'Revolut', 'Email'] 
, [userType]);
 
// If switching to Onramper, drop any previously added Stripe
useEffect(() => {
  if (userType === 'Onramper') {
    setProviders(ps => ps.filter(p => !('Stripe' in p)));
  }
}, [userType]);

5) Clean submit with backend validation

On submit, if we just returned from Stripe and the UI hasn’t yet reinstated loginMethod, we restore it from temp (single-shot) and proceed. The canister performs the heavy checks:

const handleSubmit = async () => {
    if (providers.length === 0) {
        setMessage('Please add at least one payment provider.');
        return;
    }
 
    let login: LoginAddress;
    const t = getTempUserData();
    const acct = searchParams.get('stripe_acct');
    if (t && acct && !loginMethod) {
        setLoginMethod(t.loginMethod, t.password ?? null);
        login = t.loginMethod;
    } else if (!loginMethod) {
        console.log('no login method');
        navigate("/")
        return;
    } else {
        login = loginMethod;
    }
 
    if ('Email' in login) {
        await handleEmailConfirmation();
        return;
    }
 
    let tmpActor = backend;
    if ('ICP' in login) {
        if (!backendActor) {
            setMessage("Internet Identity not loaded with backend actor")
            return;
        }
        tmpActor = backendActor;
    }
 
    setIsLoading(true);
    try {
        let result = await tmpActor.register_user(stringToUserType(userType), providers, login, []);
        if ('Err' in result) {
            setGlobalUser(null);
            setMessage(`Could not register user: ${rampErrorToString(result.Err)}`)
        }
        if ('Ok' in result) {
            navigate('/?auth=true');
        }
    } catch (error) {
        setMessage(`Failed to register user: ${error}`);
    } finally {
        setIsLoading(false);
    }
};

User Profile — Add Stripe and Email providers

Visible providers

  • OfframperStripe/PayPal/Revolut
  • OnramperEmail/PayPal/Revolut

Start onboarding from Profile

const startStripeOnboardingProfile = async () => {
    await startStripeKyc({
        userType: userTypeToString(user.user_type) as 'Offramper' | 'Onramper',
        loginMethod,
        backendActor,
        email: providerId,
        country: stripeCountry,
        storeUserData: false,
        setMessage,
        setLoading: setLoadingStripe,
    });
};

Handle return on Profile

useEffect(() => {
    const acct = searchParams.get('stripe_acct');
    const platform = searchParams.get('platform') ?? mapCountryToPlatform(stripeCountry);
    if (!acct || !platform || !sessionToken) return;
 
    setLoadingAddProvider(true);
    (async () => {
        try {
            const provider = { Stripe: { account_id: acct, platform } };
            const r = await backend.add_user_payment_provider(
                user.id,
                sessionToken,
                provider,
            );
            if ('Err' in r) {
                setMessage(`Failed to add Stripe: ${rampErrorToString(r.Err)}`);
                return;
            }
            await refetchUser();
        } catch (e: any) {
            setMessage(`Failed to finalize Stripe: ${e?.message ?? String(e)}`);
        } finally {
            setLoadingAddProvider(false);
            window.history.replaceState({}, '', window.location.pathname);
        }
    })();
}, [
    searchParams,
    stripeCountry,
    user,
    sessionToken,
    backend,
    refetchUser,
    setMessage,
]);

UI

  • Provider cards: PayPal/Revolut in gray/blue, Stripe in purple, Email in gray.
  • Stripe CTA: centered label, spinner on right, disabled while loading or once ready.
  • Success messages appear below the cards in green; red banner is errors only.

Provider cards

The list is now cards, not raw list items: readable, scannable, and consistent across light/dark.

<div className="mt-4 grid grid-cols-1 gap-3">
  {providers.map((p, i) => (
    <div key={i}
      className={cn(
        "rounded-lg border p-3",
        "'Stripe' in p ? 'border-purple-500/50 bg-purple-500/10' : 'border-gray-500/40 bg-gray-300/40 dark:bg-gray-800/60'"
      )}
    >
      {/* title + details */}
    </div>
  ))}
</div>

This adds a more modern and clean look, both in /register and /profile pages:

Register User with Stripe

Add Stripe Provider in User Profile

Onboarding CTA

Centered label, spinner on the right, and disabled state while loading or once ready.

<div
  className={clsx(
    "relative w-full flex items-center justify-center px-4 py-2 rounded-md",
    "bg-purple-600 dark:bg-purple-800 font-semibold",
    (loadingStripe || stripeReady) ? "opacity-60 cursor-not-allowed"
                                  : "cursor-pointer hover:bg-purple-500 dark:hover:bg-purple-900"
  )}
  onClick={() => !isLoading && startStripeOnboarding()}
>
  <span className="text-lg pointer-events-none">
    {loadingStripe ? <>Setting up Stripe<DynamicDots isLoading /></>
                   : stripeReady ? "Stripe Ready"
                                 : "Start Stripe Onboarding"}
  </span>
 
  {loadingStripe && (
    <div className="absolute right-3 w-4 h-4 border-t-2 border-b-2 border-purple-700 rounded-full animate-spin" />
  )}
</div>

Success notice placement

Success feedback for Stripe lives below the cards in green; the red banner at the bottom remains reserved for errors only.

Create Order — provider selection, but pretty

The “Payment Providers” block now mirrors the register cards with a selectable card/checkbox pattern.

  • Stripe card = “pay offramper via destination charge”
  • Revolut/PayPal cards show their identifiers (truncated monospace)
  • Selected state uses a subtle indigo outline/badge

Flow recap

  1. Offramper registers and links Stripe (Express). We persist progress pre-redirect and restore on return; the backend validates the connected account.
  2. Onramper registers and supplies Email (no Stripe onboarding at all).
  3. Order creation: onramper chooses amount + chain and selects a provider:
    • If Stripe is selected for the offramper, the backend creates a Checkout Session with payment_intent_data[transfer_data][destination]=acct_….
    • FE opens the session_url; success route returns session_id.
  4. Verification: FE calls backend to verify session → backend retrieves session (expanded PI), checks:
    • payment_status=paid
    • PI.status=succeeded
    • currency & amount_received match
    • transfer_data.destination == offramper.acct
  5. Release: vault sends crypto to the onramper per the order, and the order is closed.

Edge cases & guardrails we handled

  • Page refresh after Stripe: all state is restored from tempUserData. If the restored loginMethod mismatches the active one, we clear the temp payload.
  • Role toggles: switching to Onramper automatically removes Stripe providers from the list.
  • No FE secrets: the frontend never touches Stripe secret keys nor tries to infer capabilities; all checks live in the canister.
  • Idempotency: FE/BE both use idempotency keys where relevant (see backend post).

What’s next

  • Order lock + session lifecycle UI (timer, cancel/replace, retry).
  • Application fees (platform revenue): pass application_fee_amount in Checkout.
  • Email payer match for Onramper: record and verify payer email post-payment.
  • Docs + demo: a short clip of the full flow (register → link Stripe → create order → pay → release).

Stay Updated

Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.

You might also like

icRamp Devlog #15 — Stripe Order Payments (Email↔️Connect, per‑order redirects)

End-to-end Stripe payments for orders: Onramper pays by email, Offramper receives via Connect destination charges. Per‑order success/cancel, email verification, and a resilient FE redirect flow.

icRamp Devlog #16 — Liquid Orders: Top‑ups + Provider Icons

Added liquid (top‑up) orders and unified provider icons across the app. Safe processing lock on the backend, fee recomputation on the new total, and a polished top‑up UI with available-balance max.

icRamp Devlog #13 — Stripe Backend (Connect + Checkout)

Starting Milestone 2: bringing Stripe Connect (destination charges) into icRamp with IPv6-friendly HTTPS outcalls, multi-region key routing, and Candid-first tests.