From 6853ae264c64214108d8289cd3d5f96b9bbbd202 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:26:53 -0500 Subject: [PATCH] Add Stripe embedded onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update StripeConnectOnboarding component with embedded flow - Add new Stripe routes and service methods for embedded onboarding - Update EarningsStatus and EarningsDashboard to support new flow - Add required frontend dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/routes/stripe.js | 35 ++++ backend/services/stripeService.js | 16 ++ frontend/package-lock.json | 19 +++ frontend/package.json | 2 + frontend/src/components/EarningsStatus.tsx | 26 ++- .../components/StripeConnectOnboarding.tsx | 155 +++++++++++++----- frontend/src/pages/EarningsDashboard.tsx | 28 +++- frontend/src/services/api.ts | 1 + 8 files changed, 243 insertions(+), 39 deletions(-) diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js index f7e112e..a039750 100644 --- a/backend/routes/stripe.js +++ b/backend/routes/stripe.js @@ -133,6 +133,41 @@ router.post("/account-links", authenticateToken, requireVerifiedEmail, async (re } }); +// Create account session for embedded onboarding +router.post("/account-sessions", authenticateToken, requireVerifiedEmail, async (req, res, next) => { + let user = null; + try { + user = await User.findByPk(req.user.id); + + if (!user || !user.stripeConnectedAccountId) { + return res.status(400).json({ error: "No connected account found" }); + } + + const accountSession = await StripeService.createAccountSession( + user.stripeConnectedAccountId + ); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Stripe account session created", { + userId: req.user.id, + stripeConnectedAccountId: user.stripeConnectedAccountId, + }); + + res.json({ + clientSecret: accountSession.client_secret, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Stripe account session creation failed", { + error: error.message, + stack: error.stack, + userId: req.user.id, + stripeConnectedAccountId: user?.stripeConnectedAccountId, + }); + next(error); + } +}); + // Get account status router.get("/account-status", authenticateToken, async (req, res, next) => { let user = null; diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index 3474289..1a76ffd 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -64,6 +64,22 @@ class StripeService { } } + static async createAccountSession(accountId) { + try { + const accountSession = await stripe.accountSessions.create({ + account: accountId, + components: { + account_onboarding: { enabled: true }, + }, + }); + + return accountSession; + } catch (error) { + logger.error("Error creating account session", { error: error.message, stack: error.stack }); + throw error; + } + } + static async createTransfer({ amount, currency = "usd", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a9e800..a845535 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@googlemaps/js-api-loader": "^1.16.10", + "@stripe/connect-js": "^3.3.31", + "@stripe/react-connect-js": "^3.3.31", "@stripe/react-stripe-js": "^3.3.1", "@stripe/stripe-js": "^5.2.0", "@testing-library/dom": "^10.4.0", @@ -3326,6 +3328,23 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stripe/connect-js": { + "version": "3.3.31", + "resolved": "https://registry.npmjs.org/@stripe/connect-js/-/connect-js-3.3.31.tgz", + "integrity": "sha512-vjsZbatveSMoceOKZHt4eImmlBQla112OV9+enxcZUC1dGziTl2J2E3iH3n01yx9nu9ziesMJnSh/Y2d62TA/Q==", + "license": "MIT" + }, + "node_modules/@stripe/react-connect-js": { + "version": "3.3.31", + "resolved": "https://registry.npmjs.org/@stripe/react-connect-js/-/react-connect-js-3.3.31.tgz", + "integrity": "sha512-lzJbFdnlUxyILuEjdE9CqYhGq10XPvz89m/Phe/9aqXhLi02eIbaNHZOL+WQo1NMOGWeLjASnMCUagCZwoOsiA==", + "license": "MIT", + "peerDependencies": { + "@stripe/connect-js": ">=3.3.29", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@stripe/react-stripe-js": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 89bfaf4..6b608f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,8 @@ "private": true, "dependencies": { "@googlemaps/js-api-loader": "^1.16.10", + "@stripe/connect-js": "^3.3.31", + "@stripe/react-connect-js": "^3.3.31", "@stripe/react-stripe-js": "^3.3.1", "@stripe/stripe-js": "^5.2.0", "@testing-library/dom": "^10.4.0", diff --git a/frontend/src/components/EarningsStatus.tsx b/frontend/src/components/EarningsStatus.tsx index 92b7641..101dea6 100644 --- a/frontend/src/components/EarningsStatus.tsx +++ b/frontend/src/components/EarningsStatus.tsx @@ -2,11 +2,13 @@ import React from "react"; interface EarningsStatusProps { hasStripeAccount: boolean; + isOnboardingComplete?: boolean; onSetupClick: () => void; } const EarningsStatus: React.FC = ({ hasStripeAccount, + isOnboardingComplete = false, onSetupClick, }) => { // No Stripe account exists @@ -31,7 +33,29 @@ const EarningsStatus: React.FC = ({ ); } - // Account exists and is set up + // Account exists but onboarding incomplete + if (!isOnboardingComplete) { + return ( +
+
+ +
+
Setup Incomplete
+

+ Your earnings account was created but setup is not complete. Please + finish the onboarding process to start receiving payments. +

+ +
+ ); + } + + // Account exists and is fully set up return (
diff --git a/frontend/src/components/StripeConnectOnboarding.tsx b/frontend/src/components/StripeConnectOnboarding.tsx index b033532..d17d3aa 100644 --- a/frontend/src/components/StripeConnectOnboarding.tsx +++ b/frontend/src/components/StripeConnectOnboarding.tsx @@ -1,20 +1,66 @@ -import React, { useState } from "react"; +import React, { useState, useCallback } from "react"; +import { loadConnectAndInitialize } from "@stripe/connect-js"; +import { + ConnectAccountOnboarding, + ConnectComponentsProvider, +} from "@stripe/react-connect-js"; import { stripeAPI } from "../services/api"; interface StripeConnectOnboardingProps { onComplete: () => void; onCancel: () => void; + hasExistingAccount?: boolean; } const StripeConnectOnboarding: React.FC = ({ onComplete, onCancel, + hasExistingAccount = false, }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [step, setStep] = useState<"start" | "creating" | "redirecting">( - "start" + const [step, setStep] = useState<"start" | "creating" | "onboarding">( + hasExistingAccount ? "onboarding" : "start" ); + const [stripeConnectInstance, setStripeConnectInstance] = useState(null); + + const fetchClientSecret = useCallback(async () => { + const response = await stripeAPI.createAccountSession(); + return response.data.clientSecret; + }, []); + + const initializeStripeConnect = useCallback(async () => { + try { + const publishableKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY; + if (!publishableKey) { + throw new Error("Stripe publishable key not configured"); + } + + const instance = loadConnectAndInitialize({ + publishableKey, + fetchClientSecret, + appearance: { + overlays: "dialog", + variables: { + colorPrimary: "#0d6efd", + colorBackground: "#ffffff", + colorText: "#212529", + colorDanger: "#dc3545", + fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", + fontSizeBase: "16px", + borderRadius: "8px", + spacingUnit: "4px", + }, + }, + }); + + setStripeConnectInstance(instance); + } catch (err: any) { + setError(err.message || "Failed to initialize Stripe"); + setStep("start"); + setLoading(false); + } + }, [fetchClientSecret]); const createStripeAccount = async () => { setLoading(true); @@ -22,24 +68,14 @@ const StripeConnectOnboarding: React.FC = ({ setStep("creating"); try { - // First, create the Stripe Connected Account - const accountResponse = await stripeAPI.createConnectedAccount(); + // Create the Stripe Connected Account + await stripeAPI.createConnectedAccount(); - setStep("redirecting"); + // Initialize Stripe Connect for embedded onboarding + await initializeStripeConnect(); - // Generate onboarding link - const refreshUrl = `${window.location.origin}/earnings?refresh=true`; - const returnUrl = `${window.location.origin}/earnings?setup=complete`; - - const linkResponse = await stripeAPI.createAccountLink({ - refreshUrl, - returnUrl, - }); - - const { url } = linkResponse.data; - - // Redirect to Stripe onboarding - window.location.href = url; + setStep("onboarding"); + setLoading(false); } catch (err: any) { setError( err.response?.data?.error || err.message || "Failed to set up earnings" @@ -49,20 +85,47 @@ const StripeConnectOnboarding: React.FC = ({ } }; + const startOnboardingForExistingAccount = async () => { + setLoading(true); + setError(null); + + try { + await initializeStripeConnect(); + setStep("onboarding"); + setLoading(false); + } catch (err: any) { + setError(err.message || "Failed to initialize onboarding"); + setLoading(false); + } + }; + + // If user already has an account, initialize immediately + React.useEffect(() => { + if (hasExistingAccount && step === "onboarding" && !stripeConnectInstance) { + startOnboardingForExistingAccount(); + } + }, [hasExistingAccount]); + const handleStartSetup = () => { createStripeAccount(); }; + const handleOnboardingExit = () => { + onComplete(); + }; + return (
-
+
-
Set Up Earnings
+
+ {step === "onboarding" ? "Complete Your Earnings Setup" : "Set Up Earnings"} +
)} - {(step === "creating" || step === "redirecting") && ( + {step === "creating" && (
Please don't close this window...
)} + {step === "onboarding" && stripeConnectInstance && ( +
+ + Your information is securely processed by Stripe + +
+ )}
diff --git a/frontend/src/pages/EarningsDashboard.tsx b/frontend/src/pages/EarningsDashboard.tsx index 9ea612b..722767e 100644 --- a/frontend/src/pages/EarningsDashboard.tsx +++ b/frontend/src/pages/EarningsDashboard.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { Link } from "react-router-dom"; -import { rentalAPI, userAPI } from "../services/api"; +import { rentalAPI, userAPI, stripeAPI } from "../services/api"; import { Rental, User } from "../types"; import StripeConnectOnboarding from "../components/StripeConnectOnboarding"; import EarningsStatus from "../components/EarningsStatus"; @@ -12,11 +12,17 @@ interface EarningsData { rentalsWithEarnings: Rental[]; } +interface AccountStatus { + detailsSubmitted: boolean; + payoutsEnabled: boolean; +} + const EarningsDashboard: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [earningsData, setEarningsData] = useState(null); const [userProfile, setUserProfile] = useState(null); + const [accountStatus, setAccountStatus] = useState(null); const [showOnboarding, setShowOnboarding] = useState(false); useEffect(() => { @@ -28,11 +34,28 @@ const EarningsDashboard: React.FC = () => { try { const response = await userAPI.getProfile(); setUserProfile(response.data); + + // If user has a Stripe account, fetch account status + if (response.data.stripeConnectedAccountId) { + await fetchAccountStatus(); + } } catch (err) { console.error("Failed to fetch user profile:", err); } }; + const fetchAccountStatus = async () => { + try { + const response = await stripeAPI.getAccountStatus(); + setAccountStatus({ + detailsSubmitted: response.data.detailsSubmitted, + payoutsEnabled: response.data.payoutsEnabled, + }); + } catch (err) { + console.error("Failed to fetch account status:", err); + } + }; + const fetchEarningsData = async () => { try { // Get completed rentals where user is the owner @@ -99,6 +122,7 @@ const EarningsDashboard: React.FC = () => { } const hasStripeAccount = !!userProfile?.stripeConnectedAccountId; + const isOnboardingComplete = accountStatus?.detailsSubmitted ?? false; return (
@@ -243,6 +267,7 @@ const EarningsDashboard: React.FC = () => {
setShowOnboarding(true)} />
@@ -284,6 +309,7 @@ const EarningsDashboard: React.FC = () => { setShowOnboarding(false)} + hasExistingAccount={hasStripeAccount} /> )}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2a8e4c7..b82ea31 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -309,6 +309,7 @@ export const stripeAPI = { createConnectedAccount: () => api.post("/stripe/accounts"), createAccountLink: (data: { refreshUrl: string; returnUrl: string }) => api.post("/stripe/account-links", data), + createAccountSession: () => api.post("/stripe/account-sessions"), getAccountStatus: () => api.get("/stripe/account-status"), createSetupCheckoutSession: (data: { rentalData?: any }) => api.post("/stripe/create-setup-checkout-session", data),