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 (
+
+ );
+ }
+
+ return (
+
+
+
+ Delete Test Connect Accounts
+
+
+
+
⚠️ Warning
+
+ This action will delete all test Stripe Connect accounts associated
+ with this environment. This operation cannot be undone.
+
+
+
+
+ Delete All Test Connect Accounts
+
+
+
+ );
+}
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 && (
-
+
navigate("/keys")} variant="primary">
API Keys
-
navigate("/orders")} variant="default">
- Orders
-
setIsEditing(true)} variant="outline">
Edit Profile
@@ -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}
+
+
+ navigate("/orders")} variant="primary">
+ Orders
+
+ {!user.stripe_connect_account_id ? (
+
+ navigate("/sell/onboarding")}
+ variant="outline"
+ >
+ Sell Robots
+
+
+ ) : !user.stripe_connect_onboarding_completed ? (
+
+ navigate("/sell/onboarding")}
+ variant="outline"
+ >
+ Complete Seller Setup
+
+
+ ) : (
+ navigate("/sell/dashboard")}
+ variant="outline"
+ >
+ Seller Dashboard
+
+ )}
+
{
- 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 (
+
+ );
+ }
+
+ 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.
+
+
+
+ {accountCreatePending
+ ? "Creating account..."
+ : "Start seller onboarding"}
+
+
+ )}
+
+ {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))