From 0449d6f87ca00abc8c05ab2f9e56a63f01b10a25 Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 18 Nov 2024 18:18:34 +0100 Subject: [PATCH] feat: use OAuth for stripe connect --- apps/api/config/config.py | 24 +++- apps/api/config/config.yaml | 3 +- apps/api/src/routers/ee/payments.py | 21 ++- .../src/services/payments/payments_stripe.py | 79 +++++++---- .../payments/webhooks/payments_webhooks.py | 4 +- .../payments/stripe/connect/oauth/page.tsx | 124 ++++++++++++++++++ .../Payments/PaymentsConfigurationPage.tsx | 30 ++--- apps/web/middleware.ts | 22 ++++ apps/web/services/payments/payments.ts | 9 ++ 9 files changed, 266 insertions(+), 50 deletions(-) create mode 100644 apps/web/app/payments/stripe/connect/oauth/page.tsx diff --git a/apps/api/config/config.py b/apps/api/config/config.py index 8a8807a4..c9a7bd23 100644 --- a/apps/api/config/config.py +++ b/apps/api/config/config.py @@ -74,7 +74,9 @@ class RedisConfig(BaseModel): class InternalStripeConfig(BaseModel): stripe_secret_key: str | None stripe_publishable_key: str | None - stripe_webhook_secret: str | None + stripe_webhook_standard_secret: str | None + stripe_webhook_connect_secret: str | None + stripe_client_id: str | None class InternalPaymentsConfig(BaseModel): @@ -275,7 +277,9 @@ def get_learnhouse_config() -> LearnHouseConfig: # Payments config env_stripe_secret_key = os.environ.get("LEARNHOUSE_STRIPE_SECRET_KEY") env_stripe_publishable_key = os.environ.get("LEARNHOUSE_STRIPE_PUBLISHABLE_KEY") - env_stripe_webhook_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_SECRET") + env_stripe_webhook_standard_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_STANDARD_SECRET") + env_stripe_webhook_connect_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_CONNECT_SECRET") + env_stripe_client_id = os.environ.get("LEARNHOUSE_STRIPE_CLIENT_ID") stripe_secret_key = env_stripe_secret_key or yaml_config.get("payments_config", {}).get( "stripe", {} @@ -285,9 +289,17 @@ def get_learnhouse_config() -> LearnHouseConfig: "stripe", {} ).get("stripe_publishable_key") - stripe_webhook_secret = env_stripe_webhook_secret or yaml_config.get("payments_config", {}).get( + stripe_webhook_standard_secret = env_stripe_webhook_standard_secret or yaml_config.get("payments_config", {}).get( "stripe", {} - ).get("stripe_webhook_secret") + ).get("stripe_webhook_standard_secret") + + stripe_webhook_connect_secret = env_stripe_webhook_connect_secret or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_webhook_connect_secret") + + stripe_client_id = env_stripe_client_id or yaml_config.get("payments_config", {}).get( + "stripe", {} + ).get("stripe_client_id") # Create HostingConfig and DatabaseConfig objects hosting_config = HostingConfig( @@ -335,7 +347,9 @@ def get_learnhouse_config() -> LearnHouseConfig: stripe=InternalStripeConfig( stripe_secret_key=stripe_secret_key, stripe_publishable_key=stripe_publishable_key, - stripe_webhook_secret=stripe_webhook_secret + stripe_webhook_standard_secret=stripe_webhook_standard_secret, + stripe_webhook_connect_secret=stripe_webhook_connect_secret, + stripe_client_id=stripe_client_id ) ) ) diff --git a/apps/api/config/config.yaml b/apps/api/config/config.yaml index b84ef8d8..c9dcec79 100644 --- a/apps/api/config/config.yaml +++ b/apps/api/config/config.yaml @@ -41,7 +41,8 @@ payments_config: stripe: stripe_secret_key: "" stripe_publishable_key: "" - stripe_webhook_secret: "" + stripe_webhook_standard_secret: "" + stripe_client_id: "" ai_config: chromadb_config: diff --git a/apps/api/src/routers/ee/payments.py b/apps/api/src/routers/ee/payments.py index 79bece97..5b7f4ca2 100644 --- a/apps/api/src/routers/ee/payments.py +++ b/apps/api/src/routers/ee/payments.py @@ -18,7 +18,7 @@ from src.services.payments.payments_courses import ( get_courses_by_product, ) from src.services.payments.payments_users import get_owned_courses -from src.services.payments.payments_stripe import create_checkout_session, update_stripe_account_id +from src.services.payments.payments_stripe import create_checkout_session, handle_stripe_oauth_callback, update_stripe_account_id from src.services.payments.payments_access import check_course_paid_access from src.services.payments.payments_customers import get_customers from src.services.payments.payments_stripe import generate_stripe_connect_link @@ -165,7 +165,14 @@ async def api_handle_connected_accounts_stripe_webhook( request: Request, db_session: Session = Depends(get_db_session), ): - return await handle_stripe_webhook(request, db_session) + return await handle_stripe_webhook(request, "standard", db_session) + +@router.post("/stripe/webhook/connect") +async def api_handle_connected_accounts_stripe_webhook_connect( + request: Request, + db_session: Session = Depends(get_db_session), +): + return await handle_stripe_webhook(request, "connect", db_session) # Payments checkout @@ -246,3 +253,13 @@ async def api_generate_stripe_connect_link( return await generate_stripe_connect_link( request, org_id, redirect_uri, current_user, db_session ) + +@router.get("/stripe/oauth/callback") +async def stripe_oauth_callback( + request: Request, + code: str, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await handle_stripe_oauth_callback(request, org_id, code, current_user, db_session) \ No newline at end of file diff --git a/apps/api/src/services/payments/payments_stripe.py b/apps/api/src/services/payments/payments_stripe.py index be9fd42a..536fac92 100644 --- a/apps/api/src/services/payments/payments_stripe.py +++ b/apps/api/src/services/payments/payments_stripe.py @@ -52,7 +52,8 @@ async def get_stripe_internal_credentials( return { "stripe_secret_key": learnhouse_config.payments_config.stripe.stripe_secret_key, "stripe_publishable_key": learnhouse_config.payments_config.stripe.stripe_publishable_key, - "stripe_webhook_secret": learnhouse_config.payments_config.stripe.stripe_webhook_secret, + "stripe_webhook_standard_secret": learnhouse_config.payments_config.stripe.stripe_webhook_standard_secret, + "stripe_webhook_connect_secret": learnhouse_config.payments_config.stripe.stripe_webhook_connect_secret, } @@ -332,30 +333,20 @@ async def generate_stripe_connect_link( # Get credentials creds = await get_stripe_internal_credentials() stripe.api_key = creds.get("stripe_secret_key") + + # Get learnhouse config for client_id + learnhouse_config = get_learnhouse_config() + client_id = learnhouse_config.payments_config.stripe.stripe_client_id + + if not client_id: + raise HTTPException(status_code=400, detail="Stripe client ID not configured") - try: - # Try to get existing account ID - stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session) - except HTTPException: - # If no account exists, create one - stripe_account = await create_stripe_account( - request, - org_id, - "standard", - current_user, - db_session - ) - stripe_acc_id = stripe_account + state = f"org_id={org_id}" + + # Generate OAuth link for existing accounts + oauth_link = f"https://connect.stripe.com/oauth/authorize?response_type=code&client_id={client_id}&scope=read_write&redirect_uri={redirect_uri}&state={state}" - # Generate OAuth link - connect_link = stripe.AccountLink.create( - account=str(stripe_acc_id), - type="account_onboarding", - return_url=redirect_uri, - refresh_url=redirect_uri, - ) - - return {"connect_url": connect_link.url} + return {"connect_url": oauth_link} async def create_stripe_account( request: Request, @@ -438,3 +429,45 @@ async def update_stripe_account_id( ) return {"message": "Stripe account ID updated successfully"} + +async def handle_stripe_oauth_callback( + request: Request, + org_id: int, + code: str, + current_user: PublicUser | AnonymousUser | InternalUser, + db_session: Session, +): + """ + Handle the OAuth callback from Stripe and complete the account connection + """ + creds = await get_stripe_internal_credentials() + stripe.api_key = creds.get("stripe_secret_key") + + try: + # Exchange the authorization code for an access token + response = stripe.OAuth.token( + grant_type='authorization_code', + code=code, + ) + + connected_account_id = response.stripe_user_id + if not connected_account_id: + raise HTTPException(status_code=400, detail="No account ID received from Stripe") + + # Now connected_account_id is guaranteed to be a string + await update_stripe_account_id( + request, + org_id, + connected_account_id, + current_user, + db_session, + ) + + return {"success": True, "account_id": connected_account_id} + + except stripe.StripeError as e: + logging.error(f"Error connecting Stripe account: {str(e)}") + raise HTTPException( + status_code=400, + detail=f"Error connecting Stripe account: {str(e)}" + ) diff --git a/apps/api/src/services/payments/webhooks/payments_webhooks.py b/apps/api/src/services/payments/webhooks/payments_webhooks.py index 37b6b522..c357f77e 100644 --- a/apps/api/src/services/payments/webhooks/payments_webhooks.py +++ b/apps/api/src/services/payments/webhooks/payments_webhooks.py @@ -1,3 +1,4 @@ +from typing import Literal from fastapi import HTTPException, Request from sqlmodel import Session, select import stripe @@ -15,11 +16,12 @@ logger = logging.getLogger(__name__) async def handle_stripe_webhook( request: Request, + webhook_type: Literal["connect", "standard"], db_session: Session, ) -> dict: # Get Stripe credentials creds = await get_stripe_internal_credentials() - webhook_secret = creds.get('stripe_webhook_secret') + webhook_secret = creds.get(f'stripe_webhook_{webhook_type}_secret') stripe.api_key = creds.get("stripe_secret_key") if not webhook_secret: diff --git a/apps/web/app/payments/stripe/connect/oauth/page.tsx b/apps/web/app/payments/stripe/connect/oauth/page.tsx new file mode 100644 index 00000000..03b068a7 --- /dev/null +++ b/apps/web/app/payments/stripe/connect/oauth/page.tsx @@ -0,0 +1,124 @@ +'use client' +import React, { useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { useOrg } from '@components/Contexts/OrgContext' +import { getUriWithOrg } from '@services/config/config' +import { Check, Loader2, AlertTriangle } from 'lucide-react' +import { motion } from 'framer-motion' +import toast from 'react-hot-toast' +import { verifyStripeConnection } from '@services/payments/payments' +import Image from 'next/image' +import learnhouseIcon from 'public/learnhouse_bigicon_1.png' + +function StripeConnectCallback() { + const router = useRouter() + const searchParams = useSearchParams() + const session = useLHSession() as any + const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing') + const [message, setMessage] = useState('') + + useEffect(() => { + const verifyConnection = async () => { + try { + const code = searchParams.get('code') + const state = searchParams.get('state') + const orgId = state?.split('=')[1] // Extract org_id value after '=' + + if (!code || !orgId) { + throw new Error('Missing required parameters') + } + + const response = await verifyStripeConnection( + parseInt(orgId), + code, + session?.data?.tokens?.access_token + ) + + // Wait for 1 second to show processing state + await new Promise(resolve => setTimeout(resolve, 1000)) + + setStatus('success') + setMessage('Successfully connected to Stripe!') + + // Close the window after 2 seconds of showing success + setTimeout(() => { + window.close() + }, 2000) + + } catch (error) { + console.error('Error verifying Stripe connection:', error) + setStatus('error') + setMessage('Failed to complete Stripe connection') + toast.error('Failed to connect to Stripe') + } + } + + if (session) { + verifyConnection() + } + }, [session, router, searchParams]) + + return ( +
+
+
+ +
+ + +
+ {status === 'processing' && ( + <> + +

+ Completing Stripe Connection +

+

+ Please wait while we finish setting up your Stripe integration... +

+ + )} + + {status === 'success' && ( + <> +
+ +
+

{message}

+

+ You can now return to the dashboard to start using payments. +

+ + )} + + {status === 'error' && ( + <> +
+ +
+

{message}

+

+ Please try again or contact support if the problem persists. +

+ + )} +
+
+
+
+ ) +} + +export default StripeConnectCallback \ No newline at end of file diff --git a/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx b/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx index 80516cf5..b1d0ccb2 100644 --- a/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx +++ b/apps/web/components/Dashboard/Payments/PaymentsConfigurationPage.tsx @@ -5,7 +5,7 @@ import { SiStripe } from '@icons-pack/react-simple-icons' import { useLHSession } from '@components/Contexts/LHSessionContext'; import { getPaymentConfigs, initializePaymentConfig, updatePaymentConfig, deletePaymentConfig, updateStripeAccountID, getStripeOnboardingLink } from '@services/payments/payments'; import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/StyledElements/Form/Form'; -import { AlertTriangle, BarChart2, Check, Coins, CreditCard, Edit, ExternalLink, Info, Loader2, RefreshCcw, Trash2 } from 'lucide-react'; +import { AlertTriangle, BarChart2, Check, Coins, CreditCard, Edit, ExternalLink, Info, Loader2, RefreshCcw, Trash2, UnplugIcon } from 'lucide-react'; import toast from 'react-hot-toast'; import useSWR, { mutate } from 'swr'; import Modal from '@components/StyledElements/Modal/Modal'; @@ -13,6 +13,7 @@ import ConfirmationModal from '@components/StyledElements/ConfirmationModal/Conf import { Button } from '@components/ui/button'; import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert'; import { useRouter } from 'next/navigation'; +import { getUriWithoutOrg } from '@services/config/config'; const PaymentsConfigurationPage: React.FC = () => { const org = useOrg() as any; @@ -62,8 +63,8 @@ const PaymentsConfigurationPage: React.FC = () => { const handleStripeOnboarding = async () => { try { setIsOnboardingLoading(true); - const { connect_url } = await getStripeOnboardingLink(org.id, access_token, window.location.href); - router.push(connect_url); + const { connect_url } = await getStripeOnboardingLink(org.id, access_token, getUriWithoutOrg('/payments/stripe/connect/oauth')); + window.open(connect_url, '_blank'); } catch (error) { console.error('Error getting onboarding link:', error); toast.error('Failed to start Stripe onboarding'); @@ -152,37 +153,30 @@ const PaymentsConfigurationPage: React.FC = () => {
- { ( + {(!stripeConfig.provider_specific_id || !stripeConfig.active) && ( )} - - Delete Configuration + Remove Connection } functionToExecute={deleteConfig} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 32ce38d6..f98a41b0 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -22,6 +22,7 @@ export const config = { */ '/((?!api|_next|fonts|umami|examples|[\\w-]+\\.\\w+).*)', '/sitemap.xml', + '/payments/stripe/connect/oauth', ], } @@ -80,6 +81,27 @@ export default async function middleware(req: NextRequest) { return NextResponse.rewrite(new URL(`/editor${pathname}`, req.url)) } + // Check if the request is for the Stripe callback URL + if (req.nextUrl.pathname.startsWith('/payments/stripe/connect/oauth')) { + const searchParams = req.nextUrl.searchParams + const orgslug = searchParams.get('state')?.split('_')[0] // Assuming state parameter contains orgslug_randomstring + + // Construct the new URL with the required parameters + const redirectUrl = new URL('/payments/stripe/connect/oauth', req.url) + + // Preserve all original search parameters + searchParams.forEach((value, key) => { + redirectUrl.searchParams.append(key, value) + }) + + // Add orgslug if available + if (orgslug) { + redirectUrl.searchParams.set('orgslug', orgslug) + } + + return NextResponse.rewrite(redirectUrl) + } + // Auth Redirects if (pathname == '/redirect_from_auth') { if (cookie_orgslug) { diff --git a/apps/web/services/payments/payments.ts b/apps/web/services/payments/payments.ts index 58b55949..37dd7388 100644 --- a/apps/web/services/payments/payments.ts +++ b/apps/web/services/payments/payments.ts @@ -56,6 +56,15 @@ export async function getStripeOnboardingLink(orgId: number, access_token: strin return res; } +export async function verifyStripeConnection(orgId: number, code: string, access_token: string) { + const result = await fetch( + `${getAPIUrl()}payments/stripe/oauth/callback?code=${code}&org_id=${orgId}`, + RequestBodyWithAuthHeader('GET', null, null, access_token) + ); + const res = await errorHandling(result); + return res; +} + export async function deletePaymentConfig(orgId: number, id: string, access_token: string) { const result = await fetch( `${getAPIUrl()}payments/${orgId}/config?id=${id}`,