From 4352a25d42e08e40c1f943343f696c103c720027 Mon Sep 17 00:00:00 2001 From: Winston Hsiao <96440583+Winston-Hsiao@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:07:58 -0500 Subject: [PATCH] Stripe Connect Onboarding Integration (#528) * Base for stripe connect onboardnig flow * Almost done with stripe onboarding * Stripe Connect onboarding flow complete * Cleanup * More cleanup * Improve code style/structure, improve onboarding flow, remove redundant code * Clean up delete connect account route * Address code review comments --- frontend/package-lock.json | 17 ++ frontend/package.json | 2 + frontend/src/App.tsx | 16 ++ .../src/components/pages/DeleteConnect.tsx | 81 ++++++++++ frontend/src/components/pages/Profile.tsx | 68 ++++++-- .../src/components/pages/SellerDashboard.tsx | 69 ++++++++ .../src/components/pages/SellerOnboarding.tsx | 148 ++++++++++++++++++ frontend/src/components/ui/button.tsx | 2 +- frontend/src/gen/api.ts | 145 +++++++++++++++++ frontend/src/hooks/useStripeConnect.tsx | 71 +++++++++ frontend/src/lib/constants/inputs.ts | 18 --- frontend/src/lib/constants/types.ts | 18 --- store/app/crud/users.py | 21 +++ store/app/model.py | 8 + store/app/routers/orders.py | 6 +- store/app/routers/stripe.py | 145 ++++++++++++++++- 16 files changed, 777 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/pages/DeleteConnect.tsx create mode 100644 frontend/src/components/pages/SellerDashboard.tsx create mode 100644 frontend/src/components/pages/SellerOnboarding.tsx create mode 100644 frontend/src/hooks/useStripeConnect.tsx delete mode 100644 frontend/src/lib/constants/inputs.ts delete mode 100644 frontend/src/lib/constants/types.ts 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 6b6737fb..5807698b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,17 +23,20 @@ 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 OrderSuccess from "./components/pages/OrderSuccess"; import OrdersPage from "./components/pages/Orders"; 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 Terminal from "./components/pages/Terminal"; import TermsOfService from "./components/pages/TermsOfService"; @@ -82,6 +85,19 @@ const App = () => { } /> } /> + } + /> + } + /> + } + /> + } /> } /> diff --git a/frontend/src/components/pages/DeleteConnect.tsx b/frontend/src/components/pages/DeleteConnect.tsx new file mode 100644 index 00000000..5236b518 --- /dev/null +++ b/frontend/src/components/pages/DeleteConnect.tsx @@ -0,0 +1,81 @@ +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; + } + + if (!auth.currentUser?.permissions?.includes("is_admin")) { + navigate("/"); + return; + } + }, [auth.isLoading, auth.isAuthenticated]); + + const handleDeleteTestAccounts = async () => { + try { + const { data, error } = await auth.client.POST( + "/stripe/connect/delete/accounts", + {}, + ); + + if (error) { + addErrorAlert(error); + return; + } + + addAlert(`Successfully deleted ${data.count} test accounts`, "success"); + setTimeout(() => { + navigate("/sell/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. +

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/pages/Profile.tsx b/frontend/src/components/pages/Profile.tsx index 22aa921e..a4460c14 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"; @@ -187,7 +188,7 @@ export const RenderProfile = (props: RenderProfileProps) => {
-
+

{user.first_name || user.last_name ? `${user.first_name || ""} ${user.last_name || ""}` @@ -195,7 +196,8 @@ export const RenderProfile = (props: RenderProfileProps) => {

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

{user.permissions && (

@@ -211,13 +213,10 @@ export const RenderProfile = (props: RenderProfileProps) => {

{!isEditing && canEdit && ( -
+
- @@ -358,11 +357,11 @@ export const RenderProfile = (props: RenderProfileProps) => { ) : (
-

Bio

+

Bio

{user.bio ? (

{user.bio}

) : ( -

+

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

)} @@ -374,9 +373,54 @@ export const RenderProfile = (props: RenderProfileProps) => { -

Listings

+

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_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..6b77e7f3 --- /dev/null +++ b/frontend/src/components/pages/SellerDashboard.tsx @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAuthentication } from "@/hooks/useAuth"; +import { Check } from "lucide-react"; + +export default function SellerDashboard() { + const navigate = useNavigate(); + const auth = useAuthentication(); + + useEffect(() => { + auth.fetchCurrentUser(); + }, []); + + 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("/sell/onboarding"); + return; + } + }, [auth.isLoading, auth.isAuthenticated, auth.currentUser]); + + if (auth.isLoading) { + return ( +
+
+

Loading...

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

Seller Dashboard

+ +
+

Account Status

+
+ +

+ Your K-Scale 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 new file mode 100644 index 00000000..e4b792fd --- /dev/null +++ b/frontend/src/components/pages/SellerOnboarding.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import Spinner from "@/components/ui/Spinner"; +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 } = useAlertQueue(); + const [accountCreatePending, setAccountCreatePending] = useState(false); + const [onboardingExited, setOnboardingExited] = useState(false); + const [connectedAccountId, setConnectedAccountId] = useState( + auth.currentUser?.stripe_connect_account_id || "", + ); + + // Wait for user data to be loaded before initializing Stripe Connect + useEffect(() => { + if (!auth.isLoading && auth.currentUser?.stripe_connect_account_id) { + setConnectedAccountId(auth.currentUser.stripe_connect_account_id); + } + }, [auth.isLoading, auth.currentUser]); + + // Only initialize Stripe Connect when we have a valid account ID + const stripeConnectInstance = useStripeConnect(connectedAccountId || ""); + + useEffect(() => { + if (auth.currentUser?.stripe_connect_onboarding_completed) { + navigate("/sell/dashboard"); + } + }, [auth.currentUser, navigate]); + + if (auth.isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!auth.isAuthenticated) { + navigate("/login"); + return null; + } + + const handleCreateAccount = async () => { + try { + setAccountCreatePending(true); + const { data, error } = await auth.client.POST( + "/stripe/connect/account", + {}, + ); + + if (error) { + addErrorAlert(error); + return; + } + + if (data?.account_id) { + setConnectedAccountId(data.account_id); + // Refresh user data to get updated stripe connect account id + await auth.fetchCurrentUser(); + } + } catch (error) { + addErrorAlert(`Failed to create seller account: ${error}`); + } finally { + setAccountCreatePending(false); + } + }; + + const showStripeConnect = connectedAccountId && stripeConnectInstance; + + return ( +
+
+

Start Selling on K-Scale

+ + {!connectedAccountId && ( +
+

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

+ + +
+ )} + + {showStripeConnect && stripeConnectInstance && ( + + { + setOnboardingExited(true); + navigate("/sell/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. + +
+ )} + {accountCreatePending && ( +

Creating a K-Scale Stripe connected account...

+ )} + {onboardingExited &&

Account setup completed

} +
+ )} +
+
+ ); +} 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 0ac80034..ed714120 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -1064,6 +1064,57 @@ export interface paths { patch?: never; trace?: never; }; + "/stripe/connect/account": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Connect Account */ + post: operations["create_connect_account_stripe_connect_account_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stripe/connect/account/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_session_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stripe/connect/delete/accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete Test Accounts */ + post: operations["delete_test_accounts_stripe_connect_delete_accounts_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/me": { parameters: { query?: never; @@ -1290,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 */ @@ -1330,6 +1386,11 @@ export interface components { /** Session Id */ session_id: string; }; + /** CreateConnectAccountResponse */ + CreateConnectAccountResponse: { + /** Account Id */ + account_id: string; + }; /** CreateRefundsRequest */ CreateRefundsRequest: { /** Payment Intent Id */ @@ -1398,6 +1459,8 @@ export interface components { username: string | null; /** Slug */ slug: string | null; + /** Score */ + score: number; /** Views */ views: number; /** Created At */ @@ -1959,6 +2022,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: { @@ -3852,6 +3922,81 @@ export interface operations { }; }; }; + create_connect_account_stripe_connect_account_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"]["CreateConnectAccountResponse"]; + }; + }; + }; + }; + create_connect_account_session_stripe_connect_account_session_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_create_connect_account_session_stripe_connect_account_session_post"]; + }; + }; + 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"]; + }; + }; + }; + }; + delete_test_accounts_stripe_connect_delete_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_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..c5408e34 --- /dev/null +++ b/frontend/src/hooks/useStripeConnect.tsx @@ -0,0 +1,71 @@ +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"; + +export const useStripeConnect = (connectedAccountId: string) => { + const auth = useAuthentication(); + const [stripeConnectInstance, setStripeConnectInstance] = + useState(); + + useEffect(() => { + let mounted = true; + + const initializeStripeConnect = async () => { + if (connectedAccountId === "") { + return; + } + + try { + const instance = await loadConnectAndInitialize({ + publishableKey: STRIPE_PUBLISHABLE_KEY, + async fetchClientSecret() { + const { data, error } = await auth.client.POST( + "/stripe/connect/account/session", + { body: { account_id: connectedAccountId } }, + ); + + if (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; + }, + 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; +}; + +export default useStripeConnect; 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 df3a2b2f..ad1aead1 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -269,6 +269,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 set_content_manager(self, user_id: str, is_content_manager: bool) -> User: user = await self.get_user(user_id, throw_if_missing=True) if user.permissions is None: @@ -282,6 +291,18 @@ 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 + 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/model.py b/store/app/model.py index 56e5dd17..cff7fc38 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -51,6 +51,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( @@ -64,6 +66,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 @@ -80,6 +84,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: @@ -110,6 +116,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 f85bb8f3..c0e28a6d 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -1,10 +1,11 @@ """Stripe integration router for handling payments and webhooks.""" import logging +from enum import Enum 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 @@ -15,11 +16,15 @@ logger = logging.getLogger(__name__) stripe_router = APIRouter() - -# Initialize Stripe with your secret key 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: @@ -115,7 +120,36 @@ 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("Account updated: %s", account["id"]) + + # Check if this is a Connect account becoming fully onboarded + 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"]) + 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("Updated user %s Connect onboarding status to completed", user.id) + else: + logger.warning("No user found for Connect account: %s", account["id"]) + except Exception as e: + logger.error("Error updating user Connect status: %s", str(e)) + else: + logger.info("Account %s not fully onboarded yet. Capabilities: %s", account["id"], capabilities) + + elif event["type"] == "checkout.session.completed": session = event["data"]["object"] logger.info("Checkout session completed: %s", session["id"]) await handle_checkout_session_completed(session, crud) @@ -270,8 +304,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}, }, }, }, @@ -298,3 +332,102 @@ 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)) + + +class CreateConnectAccountResponse(BaseModel): + account_id: str + + +@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("Starting new account creation for user %s", 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("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") + await crud.update_user( + user.id, + { + "stripe_connect_account_id": account.id, + "stripe_connect_onboarding_completed": False, + }, + ) + + return CreateConnectAccountResponse(account_id=account.id) + except Exception as e: + logger.error("Error creating Connect account: %s", str(e), exc_info=True) + raise HTTPException(status_code=400, detail=str(e)) + + +@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: + 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("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( + account=account_id, + components={ + "account_onboarding": {"enabled": True}, + }, + ) + + logger.info("Successfully created account session for account: %s", account_id) + return {"client_secret": account_session.client_secret} + except Exception as e: + logger.error("Error creating account session: %s", str(e), exc_info=True) + raise + + +@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]: + 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) + + 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("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("Error deleting test accounts: %s", str(e)) + raise HTTPException(status_code=400, detail=str(e))