From 1be7a426b2729712aedd14d82a54ec75cc85ef55 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Tue, 5 Nov 2024 00:01:03 -0500 Subject: [PATCH 1/8] Base for stripe connect onboardnig flow --- frontend/package-lock.json | 17 ++ frontend/package.json | 2 + frontend/src/App.tsx | 5 + frontend/src/components/pages/Profile.tsx | 43 ++++- .../src/components/pages/SellerOnboarding.tsx | 160 ++++++++++++++++++ frontend/src/gen/api.ts | 135 +++++++++++++++ frontend/src/hooks/useStripeConnect.tsx | 50 ++++++ store/app/crud/users.py | 11 +- store/app/model.py | 8 + store/app/routers/orders.py | 6 +- store/app/routers/stripe.py | 126 +++++++++++++- 11 files changed, 549 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/pages/SellerOnboarding.tsx create mode 100644 frontend/src/hooks/useStripeConnect.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d2f40143..113022f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,8 @@ "@react-oauth/google": "^0.12.1", "@react-three/drei": "^9.109.2", "@react-three/fiber": "^8.16.8", + "@stripe/connect-js": "^3.3.16", + "@stripe/react-connect-js": "^3.3.18", "@stripe/stripe-js": "^1.54.2", "@types/three": "^0.168.0", "@uidotdev/usehooks": "^2.4.1", @@ -3378,6 +3380,21 @@ "react": ">= 16.3.0" } }, + "node_modules/@stripe/connect-js": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/@stripe/connect-js/-/connect-js-3.3.16.tgz", + "integrity": "sha512-lMUKJJaDl6qzjp+czNn+N6wMwFXwLawmB2jNNgds8SeR+bXCVCXevzJ8dfF92KfmexKg++hBYagF9e99sEMBJQ==" + }, + "node_modules/@stripe/react-connect-js": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/@stripe/react-connect-js/-/react-connect-js-3.3.18.tgz", + "integrity": "sha512-9cEdACkqEOUMXjY7EAvgkigHLZSVsVluWs0Zxwoehc4UoB6Zn+g0HBxwX38NGPdgYOxIvOJSViCJ0l8/IFyiFw==", + "peerDependencies": { + "@stripe/connect-js": ">=3.3.16", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@stripe/react-stripe-js": { "version": "1.16.5", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-1.16.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 417f55d7..35aa2bb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,8 @@ "@react-oauth/google": "^0.12.1", "@react-three/drei": "^9.109.2", "@react-three/fiber": "^8.16.8", + "@stripe/connect-js": "^3.3.16", + "@stripe/react-connect-js": "^3.3.18", "@stripe/stripe-js": "^1.54.2", "@types/three": "^0.168.0", "@uidotdev/usehooks": "^2.4.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0205c5cc..8b9f980b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import OrderSuccess from "./components/pages/OrderSuccess"; import OrdersPage from "./components/pages/Orders"; import PrivacyPolicy from "./components/pages/PrivacyPolicy"; import ResearchPage from "./components/pages/ResearchPage"; +import SellerOnboarding from "./components/pages/SellerOnboarding"; import StompyMini from "./components/pages/StompyMini"; import StompyPro from "./components/pages/StompyPro"; import TerminalPage from "./components/pages/Terminal"; @@ -84,6 +85,10 @@ const App = () => { } /> } /> + } + /> } /> } /> diff --git a/frontend/src/components/pages/Profile.tsx b/frontend/src/components/pages/Profile.tsx index ee901840..94105445 100644 --- a/frontend/src/components/pages/Profile.tsx +++ b/frontend/src/components/pages/Profile.tsx @@ -143,14 +143,15 @@ export const RenderProfile = (props: RenderProfileProps) => {
-
+

{user.first_name || user.last_name ? `${user.first_name || ""} ${user.last_name || ""}` : "No name set"}

- @{user.username} + @ + {user.username}

Joined on{" "} @@ -160,16 +161,40 @@ export const RenderProfile = (props: RenderProfileProps) => {

{!isEditing && canEdit && ( -
- - +
+
+ + + {!user.stripe_connect_account_id ? ( + + ) : !user.stripe_connect_onboarding_completed ? ( + + ) : ( + + )} +
)} {isAdmin && !canEdit && ( diff --git a/frontend/src/components/pages/SellerOnboarding.tsx b/frontend/src/components/pages/SellerOnboarding.tsx new file mode 100644 index 00000000..237a97e3 --- /dev/null +++ b/frontend/src/components/pages/SellerOnboarding.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; +import { useStripeConnect } from "@/hooks/useStripeConnect"; +import { + ConnectAccountOnboarding, + ConnectComponentsProvider, +} from "@stripe/react-connect-js"; + +export default function SellerOnboarding() { + const navigate = useNavigate(); + const auth = useAuthentication(); + const { addErrorAlert, addAlert } = useAlertQueue(); + const [accountCreatePending, setAccountCreatePending] = useState(false); + const [onboardingExited, setOnboardingExited] = useState(false); + const [connectedAccountId, setConnectedAccountId] = useState( + null, + ); + const stripeConnectInstance = useStripeConnect(connectedAccountId); + + const handleCreateAccount = async (isExisting: boolean = false) => { + try { + setAccountCreatePending(true); + + const { data, error } = await auth.client.POST( + "/stripe/create-connect-account", + { existing_account: isExisting }, + ); + + if (error) { + addErrorAlert(error); + return; + } + + if (isExisting && data.url) { + // Redirect to Stripe's account linking flow + window.location.href = data.url; + } else { + setConnectedAccountId(data.accountId); + addAlert("Seller account created successfully!", "success"); + } + } catch (error) { + addErrorAlert(`Failed to create seller account: ${error}`); + } finally { + setAccountCreatePending(false); + } + }; + + const handleOnboardingExit = async () => { + setOnboardingExited(true); + + try { + const { data, error } = await auth.client.POST( + "/stripe/connect-account/update-onboarding-status", + {}, + ); + + if (error) { + addErrorAlert(error); + return; + } + + if (data.onboarding_completed) { + addAlert("Seller account setup completed!", "success"); + setTimeout(() => navigate("/account"), 2000); + } else { + addErrorAlert( + "Your account setup is not complete. Please provide all required information.", + ); + setTimeout(() => window.location.reload(), 2000); + } + } catch (error) { + addErrorAlert(`Failed to update onboarding status: ${error}`); + } + }; + + if (auth.isLoading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + if (!auth.isAuthenticated) { + navigate("/login"); + return null; + } + + return ( +
+
+

Seller Onboarding

+ + {!connectedAccountId && ( +
+

+ Set up your Stripe account to start selling robots and receiving + payments. +

+ +
+ + +
+
+
+
+
+ OR +
+
+ + +
+ +

+ Already have a Stripe account? You can connect it to our platform + and start selling right away. +

+
+ )} + + {stripeConnectInstance && ( + + + + )} + + {onboardingExited && ( +
+

+ Onboarding process completed. Redirecting to your account page... +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index fce64c4a..5b10e844 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1040,6 +1040,57 @@ export interface paths { patch?: never; trace?: never; }; + "/stripe/create-connect-account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Connect Account */ + post: operations["create_connect_account_stripe_create_connect_account_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stripe/connect-account/update-onboarding-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Update Connect Account Onboarding Status */ + post: operations["update_connect_account_onboarding_status_stripe_connect_account_update_onboarding_status_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stripe/connect-account/create-session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Connect Account Session */ + post: operations["create_connect_account_session_stripe_connect_account_create_session_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/me": { parameters: { query?: never; @@ -1889,6 +1940,13 @@ export interface components { name?: string | null; /** Bio */ bio?: string | null; + /** Stripe Connect Account Id */ + stripe_connect_account_id?: string | null; + /** + * Stripe Connect Onboarding Completed + * @default false + */ + stripe_connect_onboarding_completed: boolean; }; /** UserSignup */ UserSignup: { @@ -3731,6 +3789,83 @@ export interface operations { }; }; }; + create_connect_account_stripe_create_connect_account_post: { + parameters: { + query?: { + existing_account?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_connect_account_onboarding_status_stripe_connect_account_update_onboarding_status_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + }; + }; + create_connect_account_session_stripe_connect_account_create_session_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; get_user_info_endpoint_users_me_get: { parameters: { query?: never; diff --git a/frontend/src/hooks/useStripeConnect.tsx b/frontend/src/hooks/useStripeConnect.tsx new file mode 100644 index 00000000..6a823053 --- /dev/null +++ b/frontend/src/hooks/useStripeConnect.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; + +import { + StripeConnectInstance, + loadConnectAndInitialize, +} from "@stripe/connect-js"; + +import { STRIPE_PUBLISHABLE_KEY } from "../lib/constants/env"; +import { useAuthentication } from "./useAuth"; + +export const useStripeConnect = (connectedAccountId: string | null) => { + const auth = useAuthentication(); + const [stripeConnectInstance, setStripeConnectInstance] = useState< + StripeConnectInstance | undefined + >(); + + useEffect(() => { + if (connectedAccountId) { + const fetchClientSecret = async () => { + const { data, error } = await auth.client.POST( + "/stripe/connect-account/create-session", + {}, + ); + + if (error) { + throw new Error(`Failed to create account session: ${error}`); + } + + return data.client_secret; + }; + + setStripeConnectInstance( + loadConnectAndInitialize({ + publishableKey: STRIPE_PUBLISHABLE_KEY, + fetchClientSecret, + appearance: { + overlays: "dialog", + variables: { + colorPrimary: "#ff4f00", + }, + }, + }), + ); + } + }, [connectedAccountId, auth.client]); + + return stripeConnectInstance; +}; + +export default useStripeConnect; diff --git a/store/app/crud/users.py b/store/app/crud/users.py index 8995ed9e..bfb58737 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -42,7 +42,7 @@ def __init__(self, message: str) -> None: class UserCrud(BaseCrud): @classmethod def get_gsis(cls) -> set[str]: - return super().get_gsis().union({"user_id", "email", "user_token", "username"}) + return super().get_gsis().union({"user_id", "email", "user_token", "username", "stripe_connect_account_id"}) @overload async def get_user(self, id: str, throw_if_missing: Literal[True]) -> User: ... @@ -261,6 +261,15 @@ async def generate_unique_username(self, base: str) -> str: username = f"{base}{random_suffix}" return username + async def update_stripe_connect_status(self, user_id: str, account_id: str, is_completed: bool) -> User: + """Update user's Stripe Connect status.""" + updates = {"stripe_connect_account_id": account_id, "stripe_connect_onboarding_completed": is_completed} + return await self.update_user(user_id, updates) + + async def get_users_by_stripe_connect_id(self, connect_account_id: str) -> list[User]: + """Get users by their Stripe Connect account ID.""" + return await self._get_items_from_secondary_index("stripe_connect_account_id", connect_account_id, User) + async def test_adhoc() -> None: async with UserCrud() as crud: diff --git a/store/app/model.py b/store/app/model.py index 7804dace..9c13d2c4 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -52,6 +52,8 @@ class User(StoreBaseModel): last_name: str | None = None name: str | None = None bio: str | None = None + stripe_connect_account_id: str | None = None + stripe_connect_onboarding_completed: bool = False @classmethod def create( @@ -65,6 +67,8 @@ def create( last_name: str | None = None, name: str | None = None, bio: str | None = None, + stripe_connect_account_id: str | None = None, + stripe_connect_onboarding_completed: bool = False, ) -> Self: now = int(time.time()) hashed_pw = hash_password(password) if password else None @@ -81,6 +85,8 @@ def create( last_name=last_name, name=name, bio=bio, + stripe_connect_account_id=stripe_connect_account_id, + stripe_connect_onboarding_completed=stripe_connect_onboarding_completed, ) def update_timestamp(self) -> None: @@ -111,6 +117,8 @@ class UserPublic(BaseModel): last_name: str | None = None name: str | None = None bio: str | None = None + stripe_connect_account_id: str | None = None + stripe_connect_onboarding_completed: bool = False class EmailSignUpToken(StoreBaseModel): diff --git a/store/app/routers/orders.py b/store/app/routers/orders.py index 629c5c75..f1684a96 100644 --- a/store/app/routers/orders.py +++ b/store/app/routers/orders.py @@ -8,7 +8,7 @@ from store.app.crud.base import ItemNotFoundError from store.app.db import Crud from store.app.model import Order, User -from store.app.routers.stripe import get_product +from store.app.routers import stripe from store.app.routers.users import get_session_user_with_read_permission orders_router = APIRouter() @@ -59,7 +59,7 @@ async def get_order_with_product( if order.product_id is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Order has no associated product") - product = await get_product(order.product_id) + product = await stripe.get_product(order.product_id) return OrderWithProduct(order=order, product=ProductInfo(**product)) @@ -73,7 +73,7 @@ async def get_user_orders_with_products( for order in orders: if order.product_id is None: continue # Skip orders without a product_id - product = await get_product(order.product_id) + product = await stripe.get_product(order.product_id) orders_with_products.append(OrderWithProduct(order=order, product=ProductInfo(**product))) return orders_with_products except ItemNotFoundError: diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 3257f9ce..c091b5a8 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -115,7 +115,24 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di raise HTTPException(status_code=400, detail="Invalid payload") # Handle the event - if event["type"] == "checkout.session.completed": + if event["type"] == "account.updated": + account = event["data"]["object"] + logger.info(f"Account updated: {account['id']}") + + # Check if this is a Connect account becoming fully onboarded + if account["details_submitted"] and account["payouts_enabled"]: + try: + # Find user with this Connect account ID + users = await crud.get_users_by_stripe_connect_id(account["id"]) + if users: + user = users[0] # Assume one user per Connect account + # Update user's onboarding status + await crud.update_stripe_connect_status(user.id, account["id"], is_completed=True) + logger.info(f"Updated user {user.id} Connect onboarding status to completed") + except Exception as e: + logger.error(f"Error updating user Connect status: {str(e)}") + + elif event["type"] == "checkout.session.completed": session = event["data"]["object"] logger.info(f"Checkout session completed: {session['id']}") await handle_checkout_session_completed(session, crud) @@ -299,3 +316,110 @@ async def get_product(product_id: str) -> Dict[str, Any]: } except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@stripe_router.post("/create-connect-account") +async def create_connect_account( + user: Annotated[User, Depends(get_session_user_with_read_permission)], + crud: Annotated[Crud, Depends(Crud.get)], + existing_account: bool = False, +) -> Dict[str, str]: + try: + if existing_account: + if not user.stripe_connect_account_id: + raise HTTPException(status_code=400, detail="No Stripe Connect account ID found") + + account_link = stripe.AccountLink.create( + type="account_onboarding", + account=user.stripe_connect_account_id, + refresh_url=f"{settings.site.homepage}/seller-onboarding", + return_url=f"{settings.site.homepage}/account", + collect="eventually_due", + ) + return {"url": account_link.url} + else: + # Create new account with correct settings + account = stripe.Account.create( + type="express", + country="US", + email=user.email, + capabilities={ + "card_payments": {"requested": True}, + "transfers": {"requested": True}, + }, + business_type="individual", + settings={ + "payouts": {"schedule": {"interval": "manual"}}, + }, + ) + + logger.info(f"Created Connect account: {account.id} for user: {user.id}") + + await crud.update_user( + user.id, + { + "stripe_connect_account_id": account.id, + "stripe_connect_onboarding_completed": False, + }, + ) + + return {"accountId": account.id} + except Exception as e: + logger.error(f"Error creating Connect account: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + + +@stripe_router.post("/connect-account/update-onboarding-status") +async def update_connect_account_onboarding_status( + user: Annotated[User, Depends(get_session_user_with_read_permission)], + crud: Annotated[Crud, Depends(Crud.get)], +) -> Dict[str, bool]: + try: + if not user.stripe_connect_account_id: + raise HTTPException(status_code=400, detail="No Stripe Connect account found") + + # Retrieve the account to check its status + account = stripe.Account.retrieve(user.stripe_connect_account_id) + + # Safely access capabilities + capabilities = getattr(account, "capabilities", {}) + card_payments_status = capabilities.get("card_payments") if capabilities else None + transfers_status = capabilities.get("transfers") if capabilities else None + + # Check if the account has completed onboarding + is_completed = bool( + account.details_submitted + and account.payouts_enabled + and card_payments_status == "active" + and transfers_status == "active" + ) + + # Update the user record if the status has changed + if is_completed != user.stripe_connect_onboarding_completed: + await crud.update_user(user.id, {"stripe_connect_onboarding_completed": is_completed}) + + return {"onboarding_completed": is_completed} + except Exception as e: + logger.error(f"Error updating Connect account onboarding status: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + + +@stripe_router.post("/connect-account/create-session") +async def create_connect_account_session( + user: Annotated[User, Depends(get_session_user_with_read_permission)], +) -> Dict[str, str]: + try: + if not user.stripe_connect_account_id: + raise HTTPException(status_code=400, detail="No Stripe Connect account found") + + account_session = stripe.AccountSession.create( + account=user.stripe_connect_account_id, + components={ + "account_onboarding": {"enabled": True}, + }, + ) + + return {"client_secret": account_session.client_secret} + except Exception as e: + logger.error(f"Error creating Connect account session: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) From 1881425b26e0bc7f920623ae4fb5e54ce87f4fa3 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Tue, 5 Nov 2024 23:22:09 -0500 Subject: [PATCH 2/8] Almost done with stripe onboarding --- frontend/src/App.tsx | 12 +- .../src/components/pages/DeleteConnect.tsx | 85 ++++++++ frontend/src/components/pages/Profile.tsx | 75 +++---- .../src/components/pages/SellerDashboard.tsx | 60 ++++++ .../src/components/pages/SellerOnboarding.tsx | 150 ++++++++++--- frontend/src/components/ui/button.tsx | 2 +- frontend/src/gen/api.ts | 107 ++++++++-- frontend/src/hooks/useStripeConnect.tsx | 55 +++-- store/app/routers/stripe.py | 200 ++++++++++++++---- 9 files changed, 596 insertions(+), 150 deletions(-) create mode 100644 frontend/src/components/pages/DeleteConnect.tsx create mode 100644 frontend/src/components/pages/SellerDashboard.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b9f980b..24c0d43d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,11 +22,13 @@ import Login from "@/components/pages/Login"; import Logout from "@/components/pages/Logout"; import NotFound from "@/components/pages/NotFound"; import Profile from "@/components/pages/Profile"; +import SellerDashboard from "@/components/pages/SellerDashboard"; import Signup from "@/components/pages/Signup"; import { AlertQueue, AlertQueueProvider } from "@/hooks/useAlertQueue"; import { AuthenticationProvider } from "@/hooks/useAuth"; import GDPRBanner from "./components/gdpr/gdprbanner"; +import DeleteConnect from "./components/pages/DeleteConnect"; import DownloadsPage from "./components/pages/Download"; import PlaygroundPage from "./components/pages/MujocoPlayground"; import OrderSuccess from "./components/pages/OrderSuccess"; @@ -86,9 +88,17 @@ const App = () => { } /> } /> } /> + } + /> + } + /> } /> } /> diff --git a/frontend/src/components/pages/DeleteConnect.tsx b/frontend/src/components/pages/DeleteConnect.tsx new file mode 100644 index 00000000..3b6c39be --- /dev/null +++ b/frontend/src/components/pages/DeleteConnect.tsx @@ -0,0 +1,85 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; + +export default function DeleteConnect() { + const navigate = useNavigate(); + const auth = useAuthentication(); + const { addErrorAlert, addAlert } = useAlertQueue(); + + useEffect(() => { + if (auth.isLoading) return; + + if (!auth.isAuthenticated) { + navigate("/login"); + return; + } + + // Only allow access in development + if (process.env.NODE_ENV !== "development") { + navigate("/"); + return; + } + }, [auth.isLoading, auth.isAuthenticated]); + + const handleDeleteTestAccounts = async () => { + try { + const { data, error } = await auth.client.POST( + "/stripe/connect-account/delete-test-accounts", + {}, + ); + + if (error) { + addErrorAlert(error); + return; + } + + addAlert(`Successfully deleted ${data.count} test accounts`, "success"); + setTimeout(() => { + navigate("/seller-onboarding"); + }, 2000); + } catch (error) { + addErrorAlert(`Failed to delete test accounts: ${error}`); + } + }; + + if (auth.isLoading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+

+ Delete Test Connect Accounts +

+ +
+

⚠️ Warning

+

+ This action will delete all test Stripe Connect accounts associated + with this environment. This operation cannot be undone. +

+

+ This functionality is only available in development mode. +

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/pages/Profile.tsx b/frontend/src/components/pages/Profile.tsx index 94105445..c9db25cc 100644 --- a/frontend/src/components/pages/Profile.tsx +++ b/frontend/src/components/pages/Profile.tsx @@ -5,6 +5,7 @@ import UpvotedGrid from "@/components/listings/UpvotedGrid"; import { Card, CardContent, CardHeader } from "@/components/ui/Card"; import { Input, TextArea } from "@/components/ui/Input/Input"; import Spinner from "@/components/ui/Spinner"; +import { Tooltip } from "@/components/ui/ToolTip"; import { Button } from "@/components/ui/button"; import { paths } from "@/gen/api"; import { useAlertQueue } from "@/hooks/useAlertQueue"; @@ -161,40 +162,13 @@ export const RenderProfile = (props: RenderProfileProps) => {

{!isEditing && canEdit && ( -
+
+ -
- - - {!user.stripe_connect_account_id ? ( - - ) : !user.stripe_connect_onboarding_completed ? ( - - ) : ( - - )} -
)} {isAdmin && !canEdit && ( @@ -329,7 +303,7 @@ export const RenderProfile = (props: RenderProfileProps) => { {user.bio ? (

{user.bio}

) : ( -

+

No bio set. Edit your profile to add a bio.

)} @@ -341,9 +315,38 @@ export const RenderProfile = (props: RenderProfileProps) => { -

Listings

+

Store

+
+ + {!user.stripe_connect_account_id ? ( + + + + ) : !user.stripe_connect_onboarding_completed ? ( + + ) : ( + + )} +
{ - Overview + Your Listings Upvoted diff --git a/frontend/src/components/pages/SellerDashboard.tsx b/frontend/src/components/pages/SellerDashboard.tsx new file mode 100644 index 00000000..9c119a11 --- /dev/null +++ b/frontend/src/components/pages/SellerDashboard.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAuthentication } from "@/hooks/useAuth"; + +export default function SellerDashboard() { + const navigate = useNavigate(); + const auth = useAuthentication(); + + useEffect(() => { + if (auth.isLoading) return; + + if (!auth.isAuthenticated) { + navigate("/login"); + return; + } + + // Redirect to onboarding if not completed + if (!auth.currentUser?.stripe_connect_onboarding_completed) { + navigate("/seller-onboarding"); + return; + } + }, [auth.isLoading, auth.isAuthenticated, auth.currentUser]); + + if (auth.isLoading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+

Seller Dashboard

+ +
+

Account Status

+

+ ✓ Your seller account is active and ready to receive payments +

+ + +
+
+
+ ); +} diff --git a/frontend/src/components/pages/SellerOnboarding.tsx b/frontend/src/components/pages/SellerOnboarding.tsx index 237a97e3..d458e0f5 100644 --- a/frontend/src/components/pages/SellerOnboarding.tsx +++ b/frontend/src/components/pages/SellerOnboarding.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { paths } from "@/gen/api"; import { useAlertQueue } from "@/hooks/useAlertQueue"; import { useAuthentication } from "@/hooks/useAuth"; import { useStripeConnect } from "@/hooks/useStripeConnect"; @@ -9,6 +10,9 @@ import { ConnectComponentsProvider, } from "@stripe/react-connect-js"; +type AccountStatus = + paths["/stripe/connect-account/status"]["get"]["responses"]["200"]["content"]["application/json"]; + export default function SellerOnboarding() { const navigate = useNavigate(); const auth = useAuthentication(); @@ -16,32 +20,44 @@ export default function SellerOnboarding() { const [accountCreatePending, setAccountCreatePending] = useState(false); const [onboardingExited, setOnboardingExited] = useState(false); const [connectedAccountId, setConnectedAccountId] = useState( - null, + auth.currentUser?.stripe_connect_account_id || null, ); const stripeConnectInstance = useStripeConnect(connectedAccountId); + const [accountStatus, setAccountStatus] = useState( + null, + ); - const handleCreateAccount = async (isExisting: boolean = false) => { + const handleCreateNewAccount = async () => { try { setAccountCreatePending(true); + console.log("Creating new Stripe Connect account..."); const { data, error } = await auth.client.POST( "/stripe/create-connect-account", - { existing_account: isExisting }, + {}, ); if (error) { + console.error("Error creating Connect account:", error); addErrorAlert(error); return; } - if (isExisting && data.url) { - // Redirect to Stripe's account linking flow - window.location.href = data.url; - } else { - setConnectedAccountId(data.accountId); - addAlert("Seller account created successfully!", "success"); + if (data) { + const accountId = data.account_id; + if (accountId) { + console.log("Account created successfully:", accountId); + setConnectedAccountId(accountId); + + setTimeout(() => { + checkAccountStatus(); + }, 1000); + } else { + addErrorAlert("No account ID received from server"); + } } } catch (error) { + console.error("Failed to create seller account:", error); addErrorAlert(`Failed to create seller account: ${error}`); } finally { setAccountCreatePending(false); @@ -76,6 +92,32 @@ export default function SellerOnboarding() { } }; + const checkAccountStatus = async () => { + try { + const { data, error } = await auth.client.GET( + "/stripe/connect-account/status", + ); + if (error) { + addErrorAlert(error); + return; + } + setAccountStatus(data); + } catch (error) { + addErrorAlert(`Failed to check account status: ${error}`); + } + }; + + useEffect(() => { + if (auth.currentUser?.stripe_connect_onboarding_completed) { + navigate("/seller-dashboard"); + return; + } + + if (connectedAccountId && !accountStatus) { + checkAccountStatus(); + } + }, [connectedAccountId, auth.currentUser]); + if (auth.isLoading) { return (
@@ -99,13 +141,13 @@ export default function SellerOnboarding() { {!connectedAccountId && (

- Set up your Stripe account to start selling robots and receiving - payments. + Set up your K-Scale connected Stripe account to start selling + robots and receiving payments.

+
+
+ )} -
-
-
+ {connectedAccountId && !stripeConnectInstance && ( +
+ {accountStatus?.status === "incomplete" && ( +
+
+

+ Account Setup Incomplete +

+
    + {accountStatus?.missing_requirements?.map((req: string) => ( +
  • {req}
  • + ))} +
-
- OR + +
+ {accountStatus.account_link && ( + + Complete Setup in Stripe + + )} + + Open Stripe Dashboard +
- - -
- -

- Already have a Stripe account? You can connect it to our platform - and start selling right away. -

+ )} + + {accountStatus?.status === "complete" && ( +
+

+ Account Setup Complete +

+

+ Your Stripe account is fully set up and ready to accept + payments. +

+ + Open Stripe Dashboard + +
+ )}
)} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 85c6691c..1425f789 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -16,7 +16,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-gray-1 shadow-sm hover:bg-destructive/80", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-sm hover:bg-gray-3 hover:text-gray-12", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 5b10e844..4020cda4 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1091,6 +1091,40 @@ export interface paths { patch?: never; trace?: never; }; + "/stripe/connect-account/delete-test-accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete Test Accounts */ + post: operations["delete_test_accounts_stripe_connect_account_delete_test_accounts_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stripe/connect-account/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Connect Account Status */ + get: operations["get_connect_account_status_stripe_connect_account_status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/me": { parameters: { query?: never; @@ -1330,6 +1364,19 @@ export interface components { /** Client Id */ client_id: string; }; + /** ConnectAccountStatusResponse */ + ConnectAccountStatusResponse: { + /** Status */ + status: string; + /** Message */ + message: string; + /** Missing Requirements */ + missing_requirements?: string[] | null; + /** Account Link */ + account_link?: string | null; + /** Dashboard Url */ + dashboard_url?: string | null; + }; /** CreateCheckoutSessionRequest */ CreateCheckoutSessionRequest: { /** Product Id */ @@ -1342,6 +1389,11 @@ export interface components { /** Session Id */ session_id: string; }; + /** CreateConnectAccountResponse */ + CreateConnectAccountResponse: { + /** Account Id */ + account_id: string; + }; /** CreateRefundsRequest */ CreateRefundsRequest: { /** Payment Intent Id */ @@ -3791,9 +3843,27 @@ export interface operations { }; create_connect_account_stripe_create_connect_account_post: { parameters: { - query?: { - existing_account?: boolean; + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateConnectAccountResponse"]; + }; }; + }; + }; + update_connect_account_onboarding_status_stripe_connect_account_update_onboarding_status_post: { + parameters: { + query?: never; header?: never; path?: never; cookie?: never; @@ -3807,22 +3877,35 @@ export interface operations { }; content: { "application/json": { - [key: string]: string; + [key: string]: boolean; }; }; }; - /** @description Validation Error */ - 422: { + }; + }; + create_connect_account_session_stripe_connect_account_create_session_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPValidationError"]; + "application/json": { + [key: string]: string; + }; }; }; }; }; - update_connect_account_onboarding_status_stripe_connect_account_update_onboarding_status_post: { + delete_test_accounts_stripe_connect_account_delete_test_accounts_post: { parameters: { query?: never; header?: never; @@ -3837,14 +3920,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": Record; }; }; }; }; - create_connect_account_session_stripe_connect_account_create_session_post: { + get_connect_account_status_stripe_connect_account_status_get: { parameters: { query?: never; header?: never; @@ -3859,9 +3940,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["ConnectAccountStatusResponse"]; }; }; }; diff --git a/frontend/src/hooks/useStripeConnect.tsx b/frontend/src/hooks/useStripeConnect.tsx index 6a823053..cb17f727 100644 --- a/frontend/src/hooks/useStripeConnect.tsx +++ b/frontend/src/hooks/useStripeConnect.tsx @@ -15,33 +15,52 @@ export const useStripeConnect = (connectedAccountId: string | null) => { >(); useEffect(() => { - if (connectedAccountId) { - const fetchClientSecret = async () => { - const { data, error } = await auth.client.POST( - "/stripe/connect-account/create-session", - {}, - ); - - if (error) { - throw new Error(`Failed to create account session: ${error}`); - } + let mounted = true; - return data.client_secret; - }; + const initializeStripeConnect = async () => { + if (!connectedAccountId) { + return; + } - setStripeConnectInstance( - loadConnectAndInitialize({ + try { + const instance = await loadConnectAndInitialize({ publishableKey: STRIPE_PUBLISHABLE_KEY, - fetchClientSecret, + async fetchClientSecret() { + const { data, error } = await auth.client.POST( + "/stripe/connect-account/create-session", + {}, + ); + + if (error) { + throw new Error(`Failed to create account session: ${error}`); + } + + return data.client_secret; + }, appearance: { overlays: "dialog", variables: { colorPrimary: "#ff4f00", }, }, - }), - ); - } + }); + + if (mounted) { + setStripeConnectInstance(instance); + } + } catch (error) { + console.error("Failed to initialize Stripe Connect:", error); + if (mounted) { + setStripeConnectInstance(undefined); + } + } + }; + + initializeStripeConnect(); + + return () => { + mounted = false; + }; }, [connectedAccountId, auth.client]); return stripeConnectInstance; diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index c091b5a8..c43b70db 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -1,6 +1,7 @@ """Stripe integration router for handling payments and webhooks.""" import logging +from enum import Enum from typing import Annotated, Any, Dict import stripe @@ -12,6 +13,7 @@ from store.app.routers.users import get_session_user_with_read_permission from store.settings import settings +logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) stripe_router = APIRouter() @@ -20,6 +22,12 @@ stripe.api_key = settings.stripe.secret_key +class ConnectAccountStatus(str, Enum): + NOT_CREATED = "not_created" + INCOMPLETE = "incomplete" + COMPLETE = "complete" + + @stripe_router.post("/create-payment-intent") async def create_payment_intent(request: Request) -> Dict[str, Any]: try: @@ -117,10 +125,18 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di # Handle the event if event["type"] == "account.updated": account = event["data"]["object"] - logger.info(f"Account updated: {account['id']}") + logger.info(f"[WEBHOOK] Account updated: {account['id']}") # Check if this is a Connect account becoming fully onboarded - if account["details_submitted"] and account["payouts_enabled"]: + capabilities = account.get("capabilities", {}) + is_fully_onboarded = bool( + account.get("details_submitted") + and account.get("payouts_enabled") + and capabilities.get("card_payments") == "active" + and capabilities.get("transfers") == "active" + ) + + if is_fully_onboarded: try: # Find user with this Connect account ID users = await crud.get_users_by_stripe_connect_id(account["id"]) @@ -128,9 +144,13 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di user = users[0] # Assume one user per Connect account # Update user's onboarding status await crud.update_stripe_connect_status(user.id, account["id"], is_completed=True) - logger.info(f"Updated user {user.id} Connect onboarding status to completed") + logger.info(f"[WEBHOOK] Updated user {user.id} Connect onboarding status to completed") + else: + logger.warning(f"[WEBHOOK] No user found for Connect account: {account['id']}") except Exception as e: - logger.error(f"Error updating user Connect status: {str(e)}") + logger.error(f"[WEBHOOK] Error updating user Connect status: {str(e)}") + else: + logger.info(f"[WEBHOOK] Account {account['id']} not fully onboarded yet. Capabilities: {capabilities}") elif event["type"] == "checkout.session.completed": session = event["data"]["object"] @@ -288,8 +308,8 @@ async def create_checkout_session( "fixed_amount": {"amount": 2500, "currency": "usd"}, "display_name": "Ground - Express", "delivery_estimate": { - "minimum": {"unit": "business_day", "value": 2}, - "maximum": {"unit": "business_day", "value": 5}, + "minimum": {"unit": "business_day", "value": 3}, + "maximum": {"unit": "business_day", "value": 7}, }, }, }, @@ -318,54 +338,45 @@ async def get_product(product_id: str) -> Dict[str, Any]: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@stripe_router.post("/create-connect-account") +class CreateConnectAccountResponse(BaseModel): + account_id: str + + +@stripe_router.post("/create-connect-account", response_model=CreateConnectAccountResponse) async def create_connect_account( user: Annotated[User, Depends(get_session_user_with_read_permission)], crud: Annotated[Crud, Depends(Crud.get)], - existing_account: bool = False, -) -> Dict[str, str]: +) -> CreateConnectAccountResponse: try: - if existing_account: - if not user.stripe_connect_account_id: - raise HTTPException(status_code=400, detail="No Stripe Connect account ID found") - - account_link = stripe.AccountLink.create( - type="account_onboarding", - account=user.stripe_connect_account_id, - refresh_url=f"{settings.site.homepage}/seller-onboarding", - return_url=f"{settings.site.homepage}/account", - collect="eventually_due", - ) - return {"url": account_link.url} - else: - # Create new account with correct settings - account = stripe.Account.create( - type="express", - country="US", - email=user.email, - capabilities={ - "card_payments": {"requested": True}, - "transfers": {"requested": True}, - }, - business_type="individual", - settings={ - "payouts": {"schedule": {"interval": "manual"}}, - }, - ) + logger.info(f"[CREATE-CONNECT] Starting new account creation for user {user.id}") + + # Create a Standard Connect account + account = stripe.Account.create( + type="standard", + country="US", + email=user.email, + capabilities={ + "card_payments": {"requested": True}, + "transfers": {"requested": True}, + }, + business_type="individual", + ) - logger.info(f"Created Connect account: {account.id} for user: {user.id}") + logger.info(f"[CREATE-CONNECT] Created new Connect account: {account.id} for user: {user.id}") - await crud.update_user( - user.id, - { - "stripe_connect_account_id": account.id, - "stripe_connect_onboarding_completed": False, - }, - ) + # Update user record with new Connect account ID + logger.info("[CREATE-CONNECT] Updating user record with new Connect account ID") + await crud.update_user( + user.id, + { + "stripe_connect_account_id": account.id, + "stripe_connect_onboarding_completed": False, + }, + ) - return {"accountId": account.id} + return CreateConnectAccountResponse(account_id=account.id) except Exception as e: - logger.error(f"Error creating Connect account: {str(e)}") + logger.error(f"[CREATE-CONNECT] Error creating Connect account: {str(e)}", exc_info=True) raise HTTPException(status_code=400, detail=str(e)) @@ -423,3 +434,100 @@ async def create_connect_account_session( except Exception as e: logger.error(f"Error creating Connect account session: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) + + +# @stripe_router.post("/connect-account/delete-test-accounts") +# async def delete_test_accounts( +# user: Annotated[User, Depends(get_session_user_with_read_permission)], +# crud: Annotated[Crud, Depends(Crud.get)], +# ) -> Dict[str, Any]: +# try: +# # Only allow in test mode +# if not settings.stripe.secret_key.startswith("sk_test_"): +# raise HTTPException(status_code=400, detail="This operation is only allowed in test mode") + +# deleted_accounts = [] +# accounts = stripe.Account.list(limit=100) + +# for account in accounts: +# try: +# stripe.Account.delete(account.id) +# deleted_accounts.append(account.id) +# # Update any users that had this account +# await crud.update_user_stripe_connect_reset(account.id) +# except Exception as e: +# logger.error(f"Failed to delete account {account.id}: {str(e)}") + +# return {"success": True, "deleted_accounts": deleted_accounts, "count": len(deleted_accounts)} +# except Exception as e: +# logger.error(f"Error deleting test accounts: {str(e)}") +# raise HTTPException(status_code=400, detail=str(e)) + + +class ConnectAccountStatusResponse(BaseModel): + status: str + message: str + missing_requirements: list[str] | None = None + account_link: str | None = None + dashboard_url: str | None = None + + +@stripe_router.get("/connect-account/status", response_model=ConnectAccountStatusResponse) +async def get_connect_account_status( + user: Annotated[User, Depends(get_session_user_with_read_permission)], +) -> ConnectAccountStatusResponse: + try: + if not user.stripe_connect_account_id: + return ConnectAccountStatusResponse( + status=ConnectAccountStatus.NOT_CREATED, + message="No Stripe Connect account found", + missing_requirements=["Create a new Stripe Connect account"], + ) + + account = stripe.Account.retrieve(user.stripe_connect_account_id) + capabilities = account.get("capabilities", {}) + dashboard_url = f"https://dashboard.stripe.com/{account.id}" + + is_fully_onboarded = bool( + account.get("details_submitted") + and account.get("payouts_enabled") + and capabilities.get("card_payments") == "active" + and capabilities.get("transfers") == "active" + ) + + if is_fully_onboarded: + return ConnectAccountStatusResponse( + status=ConnectAccountStatus.COMPLETE, + message="Your Stripe account is fully set up", + dashboard_url=dashboard_url, + ) + + # If not fully onboarded, create an account link for completing setup + account_link = stripe.AccountLink.create( + account=user.stripe_connect_account_id, + refresh_url=f"{settings.site.homepage}/seller-onboarding", + return_url=f"{settings.site.homepage}/seller-onboarding", + type="account_onboarding", + ) + + missing_requirements = [] + if not account.get("details_submitted"): + missing_requirements.append("Complete business details") + if not account.get("payouts_enabled"): + missing_requirements.append("Set up payouts") + if capabilities.get("card_payments") != "active": + missing_requirements.append("Enable card payments") + if capabilities.get("transfers") != "active": + missing_requirements.append("Enable transfers") + + return ConnectAccountStatusResponse( + status=ConnectAccountStatus.INCOMPLETE, + message="Your Stripe account setup is incomplete", + missing_requirements=missing_requirements, + account_link=account_link.url, + dashboard_url=dashboard_url, + ) + + except Exception as e: + logger.error(f"Error getting Connect account status: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) From 21909e9d5032d65bc1986e285c49b47e9699aaad Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Wed, 6 Nov 2024 01:16:30 -0500 Subject: [PATCH 3/8] Stripe Connect onboarding flow complete --- frontend/src/App.tsx | 11 +- frontend/src/components/pages/Profile.tsx | 30 ++- .../src/components/pages/SellerDashboard.tsx | 7 +- .../src/components/pages/SellerOnboarding.tsx | 28 +- .../pages/SellerOnboardingContinued.tsx | 248 ++++++++++++++++++ frontend/src/gen/api.ts | 37 --- frontend/src/hooks/useAuth.tsx | 4 +- 7 files changed, 296 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/pages/SellerOnboardingContinued.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 24c0d43d..d0d57f48 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,7 +28,7 @@ import { AlertQueue, AlertQueueProvider } from "@/hooks/useAlertQueue"; import { AuthenticationProvider } from "@/hooks/useAuth"; import GDPRBanner from "./components/gdpr/gdprbanner"; -import DeleteConnect from "./components/pages/DeleteConnect"; +// import DeleteConnect from "./components/pages/DeleteConnect"; import DownloadsPage from "./components/pages/Download"; import PlaygroundPage from "./components/pages/MujocoPlayground"; import OrderSuccess from "./components/pages/OrderSuccess"; @@ -36,6 +36,7 @@ import OrdersPage from "./components/pages/Orders"; import PrivacyPolicy from "./components/pages/PrivacyPolicy"; import ResearchPage from "./components/pages/ResearchPage"; import SellerOnboarding from "./components/pages/SellerOnboarding"; +import SellerOnboardingContinued from "./components/pages/SellerOnboardingContinued"; import StompyMini from "./components/pages/StompyMini"; import StompyPro from "./components/pages/StompyPro"; import TerminalPage from "./components/pages/Terminal"; @@ -91,14 +92,18 @@ const App = () => { path="/seller-onboarding" element={} /> + } + /> } /> - } - /> + /> */} } /> } /> diff --git a/frontend/src/components/pages/Profile.tsx b/frontend/src/components/pages/Profile.tsx index c9db25cc..bf81dc66 100644 --- a/frontend/src/components/pages/Profile.tsx +++ b/frontend/src/components/pages/Profile.tsx @@ -299,7 +299,7 @@ export const RenderProfile = (props: RenderProfileProps) => { ) : (
-

Bio

+

Bio

{user.bio ? (

{user.bio}

) : ( @@ -318,6 +318,20 @@ export const RenderProfile = (props: RenderProfileProps) => {

Store

+
+ {user.stripe_connect_account_id && + !user.stripe_connect_onboarding_completed ? ( +

+ Your Stripe account setup is not complete. Please resolve + outstanding requirements to begin selling robots. It may take + some time for Stripe to process your info between submissions. +

+ ) : user.stripe_connect_onboarding_completed ? ( +

+ Stripe account setup complete. +

+ ) : null} +
) : !user.stripe_connect_onboarding_completed ? ( - + + + ) : (
@@ -228,14 +226,6 @@ export default function SellerOnboarding() { )} - - {onboardingExited && ( -
-

- Onboarding process completed. Redirecting to your account page... -

-
- )}
); diff --git a/frontend/src/components/pages/SellerOnboardingContinued.tsx b/frontend/src/components/pages/SellerOnboardingContinued.tsx new file mode 100644 index 00000000..375e7176 --- /dev/null +++ b/frontend/src/components/pages/SellerOnboardingContinued.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { paths } from "@/gen/api"; +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; +import { useStripeConnect } from "@/hooks/useStripeConnect"; +import { + ConnectAccountOnboarding, + ConnectComponentsProvider, +} from "@stripe/react-connect-js"; + +type AccountStatus = + paths["/stripe/connect-account/status"]["get"]["responses"]["200"]["content"]["application/json"]; + +export default function SellerOnboardingContinued() { + const navigate = useNavigate(); + const auth = useAuthentication(); + const { addErrorAlert, addAlert } = useAlertQueue(); + const [accountCreatePending, setAccountCreatePending] = useState(false); + const [connectedAccountId, setConnectedAccountId] = useState( + auth.currentUser?.stripe_connect_account_id || null, + ); + const stripeConnectInstance = useStripeConnect(connectedAccountId); + const [accountStatus, setAccountStatus] = useState( + null, + ); + + const handleCreateNewAccount = async () => { + try { + setAccountCreatePending(true); + console.log("Creating new Stripe Connect account..."); + + const { data, error } = await auth.client.POST( + "/stripe/create-connect-account", + {}, + ); + + if (error) { + console.error("Error creating Connect account:", error); + addErrorAlert(error); + return; + } + + if (data) { + const accountId = data.account_id; + if (accountId) { + console.log("Account created successfully:", accountId); + setConnectedAccountId(accountId); + + setTimeout(() => { + checkAccountStatus(); + }, 1000); + } else { + addErrorAlert("No account ID received from server"); + } + } + } catch (error) { + addErrorAlert(`Failed to create seller account: ${error}`); + } finally { + setAccountCreatePending(false); + } + }; + + const handleOnboardingExit = async () => { + try { + const { data, error } = await auth.client.POST( + "/stripe/connect-account/update-onboarding-status", + {}, + ); + + if (error) { + addErrorAlert(error); + return; + } + + if (data.onboarding_completed) { + addAlert("Seller account setup completed!", "success"); + navigate("/seller-dashboard"); + } else { + addErrorAlert( + "Your stripe account setup is not complete. Please resolve outstanding requirements.", + ); + navigate("/account"); + } + } catch (error) { + addErrorAlert(`Failed to update onboarding status: ${error}`); + } + }; + + const checkAccountStatus = async () => { + try { + const { data, error } = await auth.client.GET( + "/stripe/connect-account/status", + ); + if (error) { + addErrorAlert(error); + return; + } + setAccountStatus(data); + } catch (error) { + addErrorAlert(`Failed to check account status: ${error}`); + } + }; + + useEffect(() => { + if (auth.isLoading) return; + + if (auth.currentUser?.stripe_connect_onboarding_completed) { + navigate("/seller-dashboard"); + return; + } + + if (!auth.currentUser?.stripe_connect_account_id) { + navigate("/seller-onboarding"); + return; + } + + if (connectedAccountId && !accountStatus) { + checkAccountStatus(); + } + }, [connectedAccountId, auth.currentUser, auth.isLoading]); + + if (auth.isLoading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + if (!auth.isAuthenticated) { + navigate("/login"); + return null; + } + + return ( +
+
+

+ Continue Setting Up Your Seller Account +

+ + {connectedAccountId && ( +

+ This usually takes a few steps/submissions. It may take some time + for Stripe to process your info between submissions. Continue + through your account page or refresh this page to check/update your + application status. +

+ )} + + {!connectedAccountId && ( +
+

+ Set up your K-Scale connected Stripe account to start selling + robots and receiving payments. +

+ +
+ +
+
+ )} + + {connectedAccountId && !stripeConnectInstance && ( +
+ {accountStatus?.status === "incomplete" && ( +
+
+

+ Account Setup Incomplete +

+
    + {accountStatus?.missing_requirements?.map((req: string) => ( +
  • {req}
  • + ))} +
+
+ +
+ {accountStatus.account_link && ( + + Complete Setup in Stripe + + )} + + Open Stripe Dashboard + +
+
+ )} + + {accountStatus?.status === "complete" && ( +
+

+ Account Setup Complete +

+

+ Your Stripe account is fully set up and ready to accept + payments. +

+ + Open Stripe Dashboard + +
+ )} +
+ )} + + {stripeConnectInstance && ( + + + + )} +
+
+ ); +} diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 4020cda4..068a99e6 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1091,23 +1091,6 @@ export interface paths { patch?: never; trace?: never; }; - "/stripe/connect-account/delete-test-accounts": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Delete Test Accounts */ - post: operations["delete_test_accounts_stripe_connect_account_delete_test_accounts_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/stripe/connect-account/status": { parameters: { query?: never; @@ -3905,26 +3888,6 @@ export interface operations { }; }; }; - delete_test_accounts_stripe_connect_account_delete_test_accounts_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; get_connect_account_status_stripe_connect_account_status_get: { parameters: { query?: never; diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 1bf9a698..c852005f 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -101,7 +101,7 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { }, [apiKeyId, client]); const fetchCurrentUser = useCallback(async () => { - if (apiKeyId && !currentUser) { + if (apiKeyId) { setIsLoading(true); const { data, error } = await client.GET("/users/public/me"); if (error) { @@ -113,7 +113,7 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { } else if (!apiKeyId) { setIsLoading(false); } - }, [apiKeyId, client, currentUser]); + }, [apiKeyId, client]); useEffect(() => { fetchCurrentUser(); From e01ff34f38a50f4d75b05b8a6845cab4fdc645b3 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Wed, 6 Nov 2024 01:29:34 -0500 Subject: [PATCH 4/8] Cleanup --- frontend/src/components/pages/SellerOnboarding.tsx | 3 --- frontend/src/components/pages/SellerOnboardingContinued.tsx | 3 --- store/app/routers/stripe.py | 1 - 3 files changed, 7 deletions(-) diff --git a/frontend/src/components/pages/SellerOnboarding.tsx b/frontend/src/components/pages/SellerOnboarding.tsx index cf9b7f4a..f31fb74b 100644 --- a/frontend/src/components/pages/SellerOnboarding.tsx +++ b/frontend/src/components/pages/SellerOnboarding.tsx @@ -29,7 +29,6 @@ export default function SellerOnboarding() { const handleCreateNewAccount = async () => { try { setAccountCreatePending(true); - console.log("Creating new Stripe Connect account..."); const { data, error } = await auth.client.POST( "/stripe/create-connect-account", @@ -37,7 +36,6 @@ export default function SellerOnboarding() { ); if (error) { - console.error("Error creating Connect account:", error); addErrorAlert(error); return; } @@ -45,7 +43,6 @@ export default function SellerOnboarding() { if (data) { const accountId = data.account_id; if (accountId) { - console.log("Account created successfully:", accountId); setConnectedAccountId(accountId); setTimeout(() => { diff --git a/frontend/src/components/pages/SellerOnboardingContinued.tsx b/frontend/src/components/pages/SellerOnboardingContinued.tsx index 375e7176..0684e669 100644 --- a/frontend/src/components/pages/SellerOnboardingContinued.tsx +++ b/frontend/src/components/pages/SellerOnboardingContinued.tsx @@ -29,7 +29,6 @@ export default function SellerOnboardingContinued() { const handleCreateNewAccount = async () => { try { setAccountCreatePending(true); - console.log("Creating new Stripe Connect account..."); const { data, error } = await auth.client.POST( "/stripe/create-connect-account", @@ -37,7 +36,6 @@ export default function SellerOnboardingContinued() { ); if (error) { - console.error("Error creating Connect account:", error); addErrorAlert(error); return; } @@ -45,7 +43,6 @@ export default function SellerOnboardingContinued() { if (data) { const accountId = data.account_id; if (accountId) { - console.log("Account created successfully:", accountId); setConnectedAccountId(accountId); setTimeout(() => { diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 70f96f26..11b9ea92 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -13,7 +13,6 @@ from store.app.routers.users import get_session_user_with_read_permission from store.settings import settings -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) stripe_router = APIRouter() From 33fc235352e709985ec9186277f7b7c26526ed76 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Wed, 6 Nov 2024 01:34:28 -0500 Subject: [PATCH 5/8] More cleanup --- frontend/src/App.tsx | 6 ++--- frontend/src/gen/api.ts | 37 ++++++++++++++++++++++++++ store/app/crud/users.py | 13 +++++++++ store/app/routers/stripe.py | 53 +++++++++++++++++++------------------ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5273e2d7..7ffb1336 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ import { AlertQueue, AlertQueueProvider } from "@/hooks/useAlertQueue"; import { AuthenticationProvider } from "@/hooks/useAuth"; import GDPRBanner from "./components/gdpr/gdprbanner"; -// import DeleteConnect from "./components/pages/DeleteConnect"; +import DeleteConnect from "./components/pages/DeleteConnect"; import DownloadsPage from "./components/pages/Download"; import OrderSuccess from "./components/pages/OrderSuccess"; import OrdersPage from "./components/pages/Orders"; @@ -98,10 +98,10 @@ const App = () => { path="/seller-dashboard" element={} /> - {/* } - /> */} + /> } /> } /> diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 40c2dd0e..3191bb0b 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1115,6 +1115,23 @@ export interface paths { patch?: never; trace?: never; }; + "/stripe/connect-account/delete-test-accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete Test Accounts */ + post: operations["delete_test_accounts_stripe_connect_account_delete_test_accounts_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/stripe/connect-account/status": { parameters: { query?: never; @@ -4011,6 +4028,26 @@ export interface operations { }; }; }; + delete_test_accounts_stripe_connect_account_delete_test_accounts_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; get_connect_account_status_stripe_connect_account_status_get: { parameters: { query?: never; diff --git a/store/app/crud/users.py b/store/app/crud/users.py index 4bd440b3..e159929c 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -291,6 +291,19 @@ async def set_content_manager(self, user_id: str, is_content_manager: bool) -> U await self._update_item(user_id, User, {"permissions": list(user.permissions)}) return user + # For testing workflow will remove once stripe connect payment and listing integration done + async def update_user_stripe_connect_reset(self, connect_account_id: str) -> None: + """Reset Stripe Connect related fields for users with the given account ID.""" + users = await self.get_users_by_stripe_connect_id(connect_account_id) + for user in users: + await self.update_user( + user.id, + { + "stripe_connect_account_id": None, + "stripe_connect_onboarding_completed": False, + }, + ) + async def test_adhoc() -> None: async with UserCrud() as crud: diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 11b9ea92..f6991136 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -434,32 +434,33 @@ async def create_connect_account_session( raise HTTPException(status_code=400, detail=str(e)) -# @stripe_router.post("/connect-account/delete-test-accounts") -# async def delete_test_accounts( -# user: Annotated[User, Depends(get_session_user_with_read_permission)], -# crud: Annotated[Crud, Depends(Crud.get)], -# ) -> Dict[str, Any]: -# try: -# # Only allow in test mode -# if not settings.stripe.secret_key.startswith("sk_test_"): -# raise HTTPException(status_code=400, detail="This operation is only allowed in test mode") - -# deleted_accounts = [] -# accounts = stripe.Account.list(limit=100) - -# for account in accounts: -# try: -# stripe.Account.delete(account.id) -# deleted_accounts.append(account.id) -# # Update any users that had this account -# await crud.update_user_stripe_connect_reset(account.id) -# except Exception as e: -# logger.error(f"Failed to delete account {account.id}: {str(e)}") - -# return {"success": True, "deleted_accounts": deleted_accounts, "count": len(deleted_accounts)} -# except Exception as e: -# logger.error(f"Error deleting test accounts: {str(e)}") -# raise HTTPException(status_code=400, detail=str(e)) +# For testing workflow will remove once stripe connect payment and listing integration done +@stripe_router.post("/connect-account/delete-test-accounts") +async def delete_test_accounts( + user: Annotated[User, Depends(get_session_user_with_read_permission)], + crud: Annotated[Crud, Depends(Crud.get)], +) -> Dict[str, Any]: + try: + # Only allow in test mode + if not settings.stripe.secret_key.startswith("sk_test_"): + raise HTTPException(status_code=400, detail="This operation is only allowed in test mode") + + deleted_accounts = [] + accounts = stripe.Account.list(limit=100) + + for account in accounts: + try: + stripe.Account.delete(account.id) + deleted_accounts.append(account.id) + # Update any users that had this account + await crud.update_user_stripe_connect_reset(account.id) + except Exception as e: + logger.error(f"Failed to delete account {account.id}: {str(e)}") + + return {"success": True, "deleted_accounts": deleted_accounts, "count": len(deleted_accounts)} + except Exception as e: + logger.error(f"Error deleting test accounts: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) class ConnectAccountStatusResponse(BaseModel): From b79d5e9452c9c37524011a53cc8ee042b8903588 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Wed, 6 Nov 2024 22:48:55 -0500 Subject: [PATCH 6/8] Improve code style/structure, improve onboarding flow, remove redundant code --- frontend/src/App.tsx | 10 +- .../src/components/pages/DeleteConnect.tsx | 10 +- frontend/src/components/pages/Profile.tsx | 6 +- .../src/components/pages/SellerDashboard.tsx | 14 +- .../src/components/pages/SellerOnboarding.tsx | 259 ++++++------------ .../pages/SellerOnboardingContinued.tsx | 245 ----------------- frontend/src/gen/api.ts | 113 ++------ frontend/src/hooks/useStripeConnect.tsx | 24 +- frontend/src/lib/constants/inputs.ts | 18 -- frontend/src/lib/constants/types.ts | 18 -- store/app/crud/users.py | 3 +- store/app/routers/stripe.py | 139 ++-------- 12 files changed, 164 insertions(+), 695 deletions(-) delete mode 100644 frontend/src/components/pages/SellerOnboardingContinued.tsx delete mode 100644 frontend/src/lib/constants/inputs.ts delete mode 100644 frontend/src/lib/constants/types.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ffb1336..5807698b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,7 +37,6 @@ import Playground from "./components/pages/Playground"; import PrivacyPolicy from "./components/pages/PrivacyPolicy"; import ResearchPage from "./components/pages/ResearchPage"; import SellerOnboarding from "./components/pages/SellerOnboarding"; -import SellerOnboardingContinued from "./components/pages/SellerOnboardingContinued"; import Terminal from "./components/pages/Terminal"; import TermsOfService from "./components/pages/TermsOfService"; @@ -87,21 +86,18 @@ const App = () => { } /> } /> } - /> - } /> } /> + } /> } /> diff --git a/frontend/src/components/pages/DeleteConnect.tsx b/frontend/src/components/pages/DeleteConnect.tsx index 3b6c39be..5236b518 100644 --- a/frontend/src/components/pages/DeleteConnect.tsx +++ b/frontend/src/components/pages/DeleteConnect.tsx @@ -17,8 +17,7 @@ export default function DeleteConnect() { return; } - // Only allow access in development - if (process.env.NODE_ENV !== "development") { + if (!auth.currentUser?.permissions?.includes("is_admin")) { navigate("/"); return; } @@ -27,7 +26,7 @@ export default function DeleteConnect() { const handleDeleteTestAccounts = async () => { try { const { data, error } = await auth.client.POST( - "/stripe/connect-account/delete-test-accounts", + "/stripe/connect/delete/accounts", {}, ); @@ -38,7 +37,7 @@ export default function DeleteConnect() { addAlert(`Successfully deleted ${data.count} test accounts`, "success"); setTimeout(() => { - navigate("/seller-onboarding"); + navigate("/sell/onboarding"); }, 2000); } catch (error) { addErrorAlert(`Failed to delete test accounts: ${error}`); @@ -68,9 +67,6 @@ export default function DeleteConnect() { This action will delete all test Stripe Connect accounts associated with this environment. This operation cannot be undone.

-

- This functionality is only available in development mode. -

-
+
)} - {connectedAccountId && !stripeConnectInstance && ( -
- {accountStatus?.status === "incomplete" && ( -
-
-

- Account Setup Incomplete -

-
    - {accountStatus?.missing_requirements?.map((req: string) => ( -
  • {req}
  • - ))} -
-
+ {showStripeConnect && stripeConnectInstance && ( + + { + setOnboardingExited(true); + navigate("/sell/dashboard"); + }} + /> + + )} -
- {accountStatus.account_link && ( - - Complete Setup in Stripe - - )} - - Open Stripe Dashboard - + {(connectedAccountId || accountCreatePending || onboardingExited) && ( +
+ {connectedAccountId && ( +
+ + Complete the onboarding process to start selling robots. + +
+ Connected account ID:{" "} + + {connectedAccountId} +
+ + This usually takes a few steps/submissions. + + + It may take some time for Stripe to process your info between + submissions. Continue through your account page or refresh + this page to check/update your application status. +
)} - - {accountStatus?.status === "complete" && ( -
-

- Account Setup Complete -

-

- Your Stripe account is fully set up and ready to accept - payments. -

- - Open Stripe Dashboard - -
+ {accountCreatePending && ( +

Creating a K-Scale Stripe connected account...

)} + {onboardingExited &&

Account setup completed

}
)} - - {stripeConnectInstance && ( - - - - )}
); diff --git a/frontend/src/components/pages/SellerOnboardingContinued.tsx b/frontend/src/components/pages/SellerOnboardingContinued.tsx deleted file mode 100644 index 0684e669..00000000 --- a/frontend/src/components/pages/SellerOnboardingContinued.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; - -import { paths } from "@/gen/api"; -import { useAlertQueue } from "@/hooks/useAlertQueue"; -import { useAuthentication } from "@/hooks/useAuth"; -import { useStripeConnect } from "@/hooks/useStripeConnect"; -import { - ConnectAccountOnboarding, - ConnectComponentsProvider, -} from "@stripe/react-connect-js"; - -type AccountStatus = - paths["/stripe/connect-account/status"]["get"]["responses"]["200"]["content"]["application/json"]; - -export default function SellerOnboardingContinued() { - const navigate = useNavigate(); - const auth = useAuthentication(); - const { addErrorAlert, addAlert } = useAlertQueue(); - const [accountCreatePending, setAccountCreatePending] = useState(false); - const [connectedAccountId, setConnectedAccountId] = useState( - auth.currentUser?.stripe_connect_account_id || null, - ); - const stripeConnectInstance = useStripeConnect(connectedAccountId); - const [accountStatus, setAccountStatus] = useState( - null, - ); - - const handleCreateNewAccount = async () => { - try { - setAccountCreatePending(true); - - const { data, error } = await auth.client.POST( - "/stripe/create-connect-account", - {}, - ); - - if (error) { - addErrorAlert(error); - return; - } - - if (data) { - const accountId = data.account_id; - if (accountId) { - setConnectedAccountId(accountId); - - setTimeout(() => { - checkAccountStatus(); - }, 1000); - } else { - addErrorAlert("No account ID received from server"); - } - } - } catch (error) { - addErrorAlert(`Failed to create seller account: ${error}`); - } finally { - setAccountCreatePending(false); - } - }; - - const handleOnboardingExit = async () => { - try { - const { data, error } = await auth.client.POST( - "/stripe/connect-account/update-onboarding-status", - {}, - ); - - if (error) { - addErrorAlert(error); - return; - } - - if (data.onboarding_completed) { - addAlert("Seller account setup completed!", "success"); - navigate("/seller-dashboard"); - } else { - addErrorAlert( - "Your stripe account setup is not complete. Please resolve outstanding requirements.", - ); - navigate("/account"); - } - } catch (error) { - addErrorAlert(`Failed to update onboarding status: ${error}`); - } - }; - - const checkAccountStatus = async () => { - try { - const { data, error } = await auth.client.GET( - "/stripe/connect-account/status", - ); - if (error) { - addErrorAlert(error); - return; - } - setAccountStatus(data); - } catch (error) { - addErrorAlert(`Failed to check account status: ${error}`); - } - }; - - useEffect(() => { - if (auth.isLoading) return; - - if (auth.currentUser?.stripe_connect_onboarding_completed) { - navigate("/seller-dashboard"); - return; - } - - if (!auth.currentUser?.stripe_connect_account_id) { - navigate("/seller-onboarding"); - return; - } - - if (connectedAccountId && !accountStatus) { - checkAccountStatus(); - } - }, [connectedAccountId, auth.currentUser, auth.isLoading]); - - if (auth.isLoading) { - return ( -
-
-

Loading...

-
-
- ); - } - - if (!auth.isAuthenticated) { - navigate("/login"); - return null; - } - - return ( -
-
-

- Continue Setting Up Your Seller Account -

- - {connectedAccountId && ( -

- This usually takes a few steps/submissions. It may take some time - for Stripe to process your info between submissions. Continue - through your account page or refresh this page to check/update your - application status. -

- )} - - {!connectedAccountId && ( -
-

- Set up your K-Scale connected Stripe account to start selling - robots and receiving payments. -

- -
- -
-
- )} - - {connectedAccountId && !stripeConnectInstance && ( -
- {accountStatus?.status === "incomplete" && ( -
-
-

- Account Setup Incomplete -

-
    - {accountStatus?.missing_requirements?.map((req: string) => ( -
  • {req}
  • - ))} -
-
- -
- {accountStatus.account_link && ( - - Complete Setup in Stripe - - )} - - Open Stripe Dashboard - -
-
- )} - - {accountStatus?.status === "complete" && ( -
-

- Account Setup Complete -

-

- Your Stripe account is fully set up and ready to accept - payments. -

- - Open Stripe Dashboard - -
- )} -
- )} - - {stripeConnectInstance && ( - - - - )} -
-
- ); -} diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 3191bb0b..ed714120 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1064,7 +1064,7 @@ export interface paths { patch?: never; trace?: never; }; - "/stripe/create-connect-account": { + "/stripe/connect/account": { parameters: { query?: never; header?: never; @@ -1074,31 +1074,14 @@ export interface paths { get?: never; put?: never; /** Create Connect Account */ - post: operations["create_connect_account_stripe_create_connect_account_post"]; + post: operations["create_connect_account_stripe_connect_account_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/stripe/connect-account/update-onboarding-status": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Update Connect Account Onboarding Status */ - post: operations["update_connect_account_onboarding_status_stripe_connect_account_update_onboarding_status_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/stripe/connect-account/create-session": { + "/stripe/connect/account/session": { parameters: { query?: never; header?: never; @@ -1108,14 +1091,14 @@ export interface paths { get?: never; put?: never; /** Create Connect Account Session */ - post: operations["create_connect_account_session_stripe_connect_account_create_session_post"]; + post: operations["create_connect_account_session_stripe_connect_account_session_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/stripe/connect-account/delete-test-accounts": { + "/stripe/connect/delete/accounts": { parameters: { query?: never; header?: never; @@ -1125,24 +1108,7 @@ export interface paths { get?: never; put?: never; /** Delete Test Accounts */ - post: operations["delete_test_accounts_stripe_connect_account_delete_test_accounts_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/stripe/connect-account/status": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Connect Account Status */ - get: operations["get_connect_account_status_stripe_connect_account_status_get"]; - put?: never; - post?: never; + post: operations["delete_test_accounts_stripe_connect_delete_accounts_post"]; delete?: never; options?: never; head?: never; @@ -1375,6 +1341,11 @@ export interface components { /** Photos */ photos?: string[]; }; + /** Body_create_connect_account_session_stripe_connect_account_session_post */ + Body_create_connect_account_session_stripe_connect_account_session_post: { + /** Account Id */ + account_id: string; + }; /** Body_pull_onshape_document_onshape_pull__listing_id__get */ Body_pull_onshape_document_onshape_pull__listing_id__get: { /** Suffix To Joint Effort */ @@ -1403,19 +1374,6 @@ export interface components { /** Client Id */ client_id: string; }; - /** ConnectAccountStatusResponse */ - ConnectAccountStatusResponse: { - /** Status */ - status: string; - /** Message */ - message: string; - /** Missing Requirements */ - missing_requirements?: string[] | null; - /** Account Link */ - account_link?: string | null; - /** Dashboard Url */ - dashboard_url?: string | null; - }; /** CreateCheckoutSessionRequest */ CreateCheckoutSessionRequest: { /** Product Id */ @@ -3964,7 +3922,7 @@ export interface operations { }; }; }; - create_connect_account_stripe_create_connect_account_post: { + create_connect_account_stripe_connect_account_post: { parameters: { query?: never; header?: never; @@ -3984,36 +3942,18 @@ export interface operations { }; }; }; - update_connect_account_onboarding_status_stripe_connect_account_update_onboarding_status_post: { + create_connect_account_session_stripe_connect_account_session_post: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: boolean; - }; - }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_create_connect_account_session_stripe_connect_account_session_post"]; }; }; - }; - create_connect_account_session_stripe_connect_account_create_session_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -4026,29 +3966,18 @@ export interface operations { }; }; }; - }; - }; - delete_test_accounts_stripe_connect_account_delete_test_accounts_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { + /** @description Validation Error */ + 422: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": components["schemas"]["HTTPValidationError"]; }; }; }; }; - get_connect_account_status_stripe_connect_account_status_get: { + delete_test_accounts_stripe_connect_delete_accounts_post: { parameters: { query?: never; header?: never; @@ -4063,7 +3992,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConnectAccountStatusResponse"]; + "application/json": Record; }; }; }; diff --git a/frontend/src/hooks/useStripeConnect.tsx b/frontend/src/hooks/useStripeConnect.tsx index cb17f727..c5408e34 100644 --- a/frontend/src/hooks/useStripeConnect.tsx +++ b/frontend/src/hooks/useStripeConnect.tsx @@ -1,24 +1,22 @@ import { useEffect, useState } from "react"; +import { useAuthentication } from "@/hooks/useAuth"; +import { STRIPE_PUBLISHABLE_KEY } from "@/lib/constants/env"; import { StripeConnectInstance, loadConnectAndInitialize, } from "@stripe/connect-js"; -import { STRIPE_PUBLISHABLE_KEY } from "../lib/constants/env"; -import { useAuthentication } from "./useAuth"; - -export const useStripeConnect = (connectedAccountId: string | null) => { +export const useStripeConnect = (connectedAccountId: string) => { const auth = useAuthentication(); - const [stripeConnectInstance, setStripeConnectInstance] = useState< - StripeConnectInstance | undefined - >(); + const [stripeConnectInstance, setStripeConnectInstance] = + useState(); useEffect(() => { let mounted = true; const initializeStripeConnect = async () => { - if (!connectedAccountId) { + if (connectedAccountId === "") { return; } @@ -27,12 +25,16 @@ export const useStripeConnect = (connectedAccountId: string | null) => { publishableKey: STRIPE_PUBLISHABLE_KEY, async fetchClientSecret() { const { data, error } = await auth.client.POST( - "/stripe/connect-account/create-session", - {}, + "/stripe/connect/account/session", + { body: { account_id: connectedAccountId } }, ); if (error) { - throw new Error(`Failed to create account session: ${error}`); + console.error("Stripe session creation error:", error); + } + + if (!data?.client_secret) { + throw new Error("No client secret returned from server"); } return data.client_secret; diff --git a/frontend/src/lib/constants/inputs.ts b/frontend/src/lib/constants/inputs.ts deleted file mode 100644 index 25d4ae80..00000000 --- a/frontend/src/lib/constants/inputs.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const isValidEmail = (email: string) => { - return email.length >= 5 && email.length <= 100 && email.includes("@"); -}; - -export const EMAIL_MESSAGE = "Email must be between 5 and 100 characters."; - -export const isValidPassword = (password: string) => { - return ( - password.length >= 8 && - password.length <= 128 && - password.match(/[a-z]/) && - password.match(/[A-Z]/) && - password.match(/[0-9]/) - ); -}; - -export const PASSWORD_MESSAGE = - "Password must be between 8 and 128 characters and contain a lowercase letter, uppercase letter, and number."; diff --git a/frontend/src/lib/constants/types.ts b/frontend/src/lib/constants/types.ts deleted file mode 100644 index 8ea34df5..00000000 --- a/frontend/src/lib/constants/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface QueryIdsRequest { - ids: number[]; -} - -export interface SingleIdResponse { - id: number; - name: string; - source: string; - created: Date; - num_frames: number; - num_channels: number; - sample_rate: number; - duration: number; -} - -export interface QueryIdsResponse { - infos: SingleIdResponse[]; -} diff --git a/store/app/crud/users.py b/store/app/crud/users.py index e159929c..ad1aead1 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -42,7 +42,7 @@ def __init__(self, message: str) -> None: class UserCrud(BaseCrud): @classmethod def get_gsis(cls) -> set[str]: - return super().get_gsis().union({"user_id", "email", "user_token", "username", "stripe_connect_account_id"}) + return super().get_gsis().union({"user_id", "email", "user_token", "username"}) @overload async def get_user(self, id: str, throw_if_missing: Literal[True]) -> User: ... @@ -291,7 +291,6 @@ async def set_content_manager(self, user_id: str, is_content_manager: bool) -> U await self._update_item(user_id, User, {"permissions": list(user.permissions)}) return user - # For testing workflow will remove once stripe connect payment and listing integration done async def update_user_stripe_connect_reset(self, connect_account_id: str) -> None: """Reset Stripe Connect related fields for users with the given account ID.""" users = await self.get_users_by_stripe_connect_id(connect_account_id) diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index f6991136..9df7aca3 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -5,7 +5,7 @@ from typing import Annotated, Any, Dict import stripe -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Body, Depends, HTTPException, Request, status from pydantic import BaseModel from store.app.db import Crud @@ -16,8 +16,6 @@ logger = logging.getLogger(__name__) stripe_router = APIRouter() - -# Initialize Stripe with your secret key stripe.api_key = settings.stripe.secret_key @@ -340,13 +338,13 @@ class CreateConnectAccountResponse(BaseModel): account_id: str -@stripe_router.post("/create-connect-account", response_model=CreateConnectAccountResponse) +@stripe_router.post("/connect/account", response_model=CreateConnectAccountResponse) async def create_connect_account( user: Annotated[User, Depends(get_session_user_with_read_permission)], crud: Annotated[Crud, Depends(Crud.get)], ) -> CreateConnectAccountResponse: try: - logger.info(f"[CREATE-CONNECT] Starting new account creation for user {user.id}") + logger.info(f"Starting new account creation for user {user.id}") # Create a Standard Connect account account = stripe.Account.create( @@ -360,10 +358,10 @@ async def create_connect_account( business_type="individual", ) - logger.info(f"[CREATE-CONNECT] Created new Connect account: {account.id} for user: {user.id}") + logger.info(f"Created new Connect account: {account.id} for user: {user.id}") # Update user record with new Connect account ID - logger.info("[CREATE-CONNECT] Updating user record with new Connect account ID") + logger.info("Updating user record with new Connect account ID") await crud.update_user( user.id, { @@ -378,64 +376,38 @@ async def create_connect_account( raise HTTPException(status_code=400, detail=str(e)) -@stripe_router.post("/connect-account/update-onboarding-status") -async def update_connect_account_onboarding_status( - user: Annotated[User, Depends(get_session_user_with_read_permission)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> Dict[str, bool]: - try: - if not user.stripe_connect_account_id: - raise HTTPException(status_code=400, detail="No Stripe Connect account found") - - # Retrieve the account to check its status - account = stripe.Account.retrieve(user.stripe_connect_account_id) - - # Safely access capabilities - capabilities = getattr(account, "capabilities", {}) - card_payments_status = capabilities.get("card_payments") if capabilities else None - transfers_status = capabilities.get("transfers") if capabilities else None - - # Check if the account has completed onboarding - is_completed = bool( - account.details_submitted - and account.payouts_enabled - and card_payments_status == "active" - and transfers_status == "active" - ) - - # Update the user record if the status has changed - if is_completed != user.stripe_connect_onboarding_completed: - await crud.update_user(user.id, {"stripe_connect_onboarding_completed": is_completed}) - - return {"onboarding_completed": is_completed} - except Exception as e: - logger.error(f"Error updating Connect account onboarding status: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - - -@stripe_router.post("/connect-account/create-session") +@stripe_router.post("/connect/account/session") async def create_connect_account_session( user: Annotated[User, Depends(get_session_user_with_read_permission)], + account_id: str = Body(..., embed=True), ) -> Dict[str, str]: try: - if not user.stripe_connect_account_id: - raise HTTPException(status_code=400, detail="No Stripe Connect account found") + logger.info(f"Creating account session for account: {account_id}") + + if not account_id: + logger.error("No account ID provided in request body") + raise HTTPException(status_code=400, detail="No account ID provided") + + if user.stripe_connect_account_id != account_id: + logger.error(f"Account ID mismatch. User: {user.stripe_connect_account_id}, Requested: {account_id}") + raise HTTPException(status_code=400, detail="Account ID does not match user's connected account") account_session = stripe.AccountSession.create( - account=user.stripe_connect_account_id, + account=account_id, components={ "account_onboarding": {"enabled": True}, }, ) + logger.info(f"Successfully created account session for account: {account_id}") return {"client_secret": account_session.client_secret} except Exception as e: - logger.error(f"Error creating Connect account session: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error(f"Error creating account session: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) # For testing workflow will remove once stripe connect payment and listing integration done -@stripe_router.post("/connect-account/delete-test-accounts") +@stripe_router.post("/connect/delete/accounts") async def delete_test_accounts( user: Annotated[User, Depends(get_session_user_with_read_permission)], crud: Annotated[Crud, Depends(Crud.get)], @@ -461,72 +433,3 @@ async def delete_test_accounts( except Exception as e: logger.error(f"Error deleting test accounts: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) - - -class ConnectAccountStatusResponse(BaseModel): - status: str - message: str - missing_requirements: list[str] | None = None - account_link: str | None = None - dashboard_url: str | None = None - - -@stripe_router.get("/connect-account/status", response_model=ConnectAccountStatusResponse) -async def get_connect_account_status( - user: Annotated[User, Depends(get_session_user_with_read_permission)], -) -> ConnectAccountStatusResponse: - try: - if not user.stripe_connect_account_id: - return ConnectAccountStatusResponse( - status=ConnectAccountStatus.NOT_CREATED, - message="No Stripe Connect account found", - missing_requirements=["Create a new Stripe Connect account"], - ) - - account = stripe.Account.retrieve(user.stripe_connect_account_id) - capabilities = account.get("capabilities", {}) - dashboard_url = f"https://dashboard.stripe.com/{account.id}" - - is_fully_onboarded = bool( - account.get("details_submitted") - and account.get("payouts_enabled") - and capabilities.get("card_payments") == "active" - and capabilities.get("transfers") == "active" - ) - - if is_fully_onboarded: - return ConnectAccountStatusResponse( - status=ConnectAccountStatus.COMPLETE, - message="Your Stripe account is fully set up", - dashboard_url=dashboard_url, - ) - - # If not fully onboarded, create an account link for completing setup - account_link = stripe.AccountLink.create( - account=user.stripe_connect_account_id, - refresh_url=f"{settings.site.homepage}/seller-onboarding", - return_url=f"{settings.site.homepage}/seller-onboarding", - type="account_onboarding", - ) - - missing_requirements = [] - if not account.get("details_submitted"): - missing_requirements.append("Complete business details") - if not account.get("payouts_enabled"): - missing_requirements.append("Set up payouts") - if capabilities.get("card_payments") != "active": - missing_requirements.append("Enable card payments") - if capabilities.get("transfers") != "active": - missing_requirements.append("Enable transfers") - - return ConnectAccountStatusResponse( - status=ConnectAccountStatus.INCOMPLETE, - message="Your Stripe account setup is incomplete", - missing_requirements=missing_requirements, - account_link=account_link.url, - dashboard_url=dashboard_url, - ) - - except Exception as e: - logger.error(f"Error getting Connect account status: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) From 3b59e1a45c35e60cbacca27174a3f8047c56b419 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Wed, 6 Nov 2024 22:56:29 -0500 Subject: [PATCH 7/8] Clean up delete connect account route --- store/app/routers/stripe.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 9df7aca3..59c6c9f6 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -406,17 +406,15 @@ async def create_connect_account_session( raise HTTPException(status_code=500, detail=str(e)) -# For testing workflow will remove once stripe connect payment and listing integration done @stripe_router.post("/connect/delete/accounts") async def delete_test_accounts( user: Annotated[User, Depends(get_session_user_with_read_permission)], crud: Annotated[Crud, Depends(Crud.get)], ) -> Dict[str, Any]: - try: - # Only allow in test mode - if not settings.stripe.secret_key.startswith("sk_test_"): - raise HTTPException(status_code=400, detail="This operation is only allowed in test mode") + if not user.permissions or "is_admin" not in user.permissions: + raise HTTPException(status_code=403, detail="Admin permission required to delete accounts") + try: deleted_accounts = [] accounts = stripe.Account.list(limit=100) From 48a5e07f2f7d6b06a92906b4815d26814906f940 Mon Sep 17 00:00:00 2001 From: Winston Hsiao Date: Wed, 6 Nov 2024 23:04:10 -0500 Subject: [PATCH 8/8] Address code review comments --- .../src/components/pages/SellerOnboarding.tsx | 4 +-- store/app/routers/stripe.py | 30 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/pages/SellerOnboarding.tsx b/frontend/src/components/pages/SellerOnboarding.tsx index 69d416fa..e4b792fd 100644 --- a/frontend/src/components/pages/SellerOnboarding.tsx +++ b/frontend/src/components/pages/SellerOnboarding.tsx @@ -76,9 +76,7 @@ export default function SellerOnboarding() { } }; - const showStripeConnect = Boolean( - connectedAccountId && stripeConnectInstance, - ); + const showStripeConnect = connectedAccountId && stripeConnectInstance; return (
diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 59c6c9f6..c0e28a6d 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -122,7 +122,7 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di # Handle the event if event["type"] == "account.updated": account = event["data"]["object"] - logger.info(f"[WEBHOOK] Account updated: {account['id']}") + logger.info("Account updated: %s", account["id"]) # Check if this is a Connect account becoming fully onboarded capabilities = account.get("capabilities", {}) @@ -141,13 +141,13 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di user = users[0] # Assume one user per Connect account # Update user's onboarding status await crud.update_stripe_connect_status(user.id, account["id"], is_completed=True) - logger.info(f"[WEBHOOK] Updated user {user.id} Connect onboarding status to completed") + logger.info("Updated user %s Connect onboarding status to completed", user.id) else: - logger.warning(f"[WEBHOOK] No user found for Connect account: {account['id']}") + logger.warning("No user found for Connect account: %s", account["id"]) except Exception as e: - logger.error(f"[WEBHOOK] Error updating user Connect status: {str(e)}") + logger.error("Error updating user Connect status: %s", str(e)) else: - logger.info(f"[WEBHOOK] Account {account['id']} not fully onboarded yet. Capabilities: {capabilities}") + logger.info("Account %s not fully onboarded yet. Capabilities: %s", account["id"], capabilities) elif event["type"] == "checkout.session.completed": session = event["data"]["object"] @@ -344,7 +344,7 @@ async def create_connect_account( crud: Annotated[Crud, Depends(Crud.get)], ) -> CreateConnectAccountResponse: try: - logger.info(f"Starting new account creation for user {user.id}") + logger.info("Starting new account creation for user %s", user.id) # Create a Standard Connect account account = stripe.Account.create( @@ -358,7 +358,7 @@ async def create_connect_account( business_type="individual", ) - logger.info(f"Created new Connect account: {account.id} for user: {user.id}") + logger.info("Created new Connect account: %s for user: %s", account.id, user.id) # Update user record with new Connect account ID logger.info("Updating user record with new Connect account ID") @@ -372,7 +372,7 @@ async def create_connect_account( return CreateConnectAccountResponse(account_id=account.id) except Exception as e: - logger.error(f"[CREATE-CONNECT] Error creating Connect account: {str(e)}", exc_info=True) + logger.error("Error creating Connect account: %s", str(e), exc_info=True) raise HTTPException(status_code=400, detail=str(e)) @@ -382,14 +382,14 @@ async def create_connect_account_session( account_id: str = Body(..., embed=True), ) -> Dict[str, str]: try: - logger.info(f"Creating account session for account: {account_id}") + logger.info("Creating account session for account: %s", account_id) if not account_id: logger.error("No account ID provided in request body") raise HTTPException(status_code=400, detail="No account ID provided") if user.stripe_connect_account_id != account_id: - logger.error(f"Account ID mismatch. User: {user.stripe_connect_account_id}, Requested: {account_id}") + logger.error("Account ID mismatch. User: %s, Requested: %s", user.stripe_connect_account_id, account_id) raise HTTPException(status_code=400, detail="Account ID does not match user's connected account") account_session = stripe.AccountSession.create( @@ -399,11 +399,11 @@ async def create_connect_account_session( }, ) - logger.info(f"Successfully created account session for account: {account_id}") + logger.info("Successfully created account session for account: %s", account_id) return {"client_secret": account_session.client_secret} except Exception as e: - logger.error(f"Error creating account session: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) + logger.error("Error creating account session: %s", str(e), exc_info=True) + raise @stripe_router.post("/connect/delete/accounts") @@ -425,9 +425,9 @@ async def delete_test_accounts( # Update any users that had this account await crud.update_user_stripe_connect_reset(account.id) except Exception as e: - logger.error(f"Failed to delete account {account.id}: {str(e)}") + logger.error("Failed to delete account %s: %s", account.id, str(e)) return {"success": True, "deleted_accounts": deleted_accounts, "count": len(deleted_accounts)} except Exception as e: - logger.error(f"Error deleting test accounts: {str(e)}") + logger.error("Error deleting test accounts: %s", str(e)) raise HTTPException(status_code=400, detail=str(e))