From fa13d34ac03e06e11b084b8cbc307b4b8be24738 Mon Sep 17 00:00:00 2001 From: Winston Hsiao <96440583+Winston-Hsiao@users.noreply.github.com> Date: Thu, 14 Nov 2024 20:43:20 -0500 Subject: [PATCH] Refine Preorder (#605) * No longer duplicate save payment methods * Save multiple cards/payment methods, fixed standard checkout * Stripe connect specific webhook endpoint * UI improvements --- .github/workflows/test.yml | 1 + CONTRIBUTING.md | 9 +- env.sh.example | 2 + frontend/src/components/pages/Profile.tsx | 16 +- .../src/components/pages/SellerDashboard.tsx | 16 +- frontend/src/components/ui/Input/Input.tsx | 8 + frontend/src/gen/api.ts | 57 ++++++- store/app/model.py | 5 + store/app/routers/stripe.py | 152 ++++++++++++------ store/settings/environment.py | 1 + 10 files changed, 200 insertions(+), 67 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0727ad8a..85c6ada0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,7 @@ env: VITE_STRIPE_PUBLISHABLE_KEY: ${{ secrets.VITE_STRIPE_PUBLISHABLE_KEY }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + STRIPE_CONNECT_WEBHOOK_SECRET: ${{ secrets.STRIPE_CONNECT_WEBHOOK_SECRET }} jobs: run-tests: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4912fd3c..07674007 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,7 @@ export ONSHAPE_SECRET_KEY='' export VITE_STRIPE_PUBLISHABLE_KEY='' export STRIPE_SECRET_KEY='' export STRIPE_WEBHOOK_SECRET='' +export STRIPE_CONNECT_WEBHOOK_SECRET='' ``` ### Google OAuth Configuration @@ -264,7 +265,13 @@ Run this to recieve stripe webhooks locally: stripe listen --forward-to localhost:8080/stripe/webhook ``` -Make sure to set the `STRIPE_WEBHOOK_SECRET` environment variable to the value +Run this to recieve stripe connect webhooks locally: + +```bash +stripe listen --forward-connect-to localhost:8080/stripe/connect/webhook +``` + +Make sure to set the `STRIPE_WEBHOOK_SECRET` and `STRIPE_CONNECT_WEBHOOK_SECRET` environment variables to the values shown in the terminal and source it to the terminal you are running `make start-backend` in. diff --git a/env.sh.example b/env.sh.example index 331098fc..70696eb7 100644 --- a/env.sh.example +++ b/env.sh.example @@ -34,3 +34,5 @@ export ONSHAPE_SECRET_KEY='' export VITE_STRIPE_PUBLISHABLE_KEY='' export STRIPE_SECRET_KEY='' export STRIPE_WEBHOOK_SECRET='' +export STRIPE_CONNECT_WEBHOOK_SECRET='' + diff --git a/frontend/src/components/pages/Profile.tsx b/frontend/src/components/pages/Profile.tsx index 4a5db159..5e4e609c 100644 --- a/frontend/src/components/pages/Profile.tsx +++ b/frontend/src/components/pages/Profile.tsx @@ -293,13 +293,15 @@ export const RenderProfile = (props: RenderProfileProps) => {

You must complete seller onboarding to sell robots

)} -
- +
+ + + {!user.stripe_connect_account_id ? ( +
Open Stripe Dashboard +
diff --git a/frontend/src/components/ui/Input/Input.tsx b/frontend/src/components/ui/Input/Input.tsx index 39a32c85..d29af729 100644 --- a/frontend/src/components/ui/Input/Input.tsx +++ b/frontend/src/components/ui/Input/Input.tsx @@ -6,9 +6,17 @@ const Input = React.forwardRef< HTMLInputElement, React.InputHTMLAttributes >(({ className, type, ...props }, ref) => { + // Add wheel event handler to prevent scroll adjusting number inputs + const handleWheel = (e: React.WheelEvent) => { + if (type === "number") { + e.currentTarget.blur(); + } + }; + return ( Self: "being_assembled", "shipped", "delivered", + "preorder_placed", "cancelled", "refunded", "failed", @@ -608,6 +609,10 @@ class Order(StoreBaseModel): shipping_state: str | None = None shipping_postal_code: str | None = None shipping_country: str | None = None + shipped_at: int | None = None + delivered_at: int | None = None + cancelled_at: int | None = None + refunded_at: int | None = None @classmethod def create( diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 8295a67f..a7bd54f7 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -103,14 +103,45 @@ async def refund_payment_intent( @router.post("/webhook") async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Dict[str, str]: + """Handle direct account webhooks (non-Connect events).""" payload = await request.body() sig_header = request.headers.get("stripe-signature") try: event = stripe.Webhook.construct_event(payload, sig_header, settings.stripe.webhook_secret) - logger.info("Webhook event type: %s", event["type"]) + logger.info("Direct webhook event type: %s", event[str]) - # Handle the event + # Handle direct account events + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + await handle_checkout_session_completed(session, crud) + elif event["type"] == "payment_intent.succeeded": + payment_intent = event["data"]["object"] + logger.info("Payment intent succeeded: %s", payment_intent["id"]) + + return {"status": "success"} + except Exception as e: + logger.error("Error processing direct webhook: %s", str(e)) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/connect/webhook") +async def stripe_connect_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Dict[str, str]: + """Handle Connect account webhooks.""" + payload = await request.body() + sig_header = request.headers.get("stripe-signature") + + try: + event = stripe.Webhook.construct_event(payload, sig_header, settings.stripe.connect_webhook_secret) + logger.info("Connect webhook event type: %s", event["type"]) + + # Get the connected account ID + connected_account_id = event.get("account") + if not connected_account_id: + logger.warning("No connected account ID in webhook event") + return {"status": "skipped"} + + # Handle Connect-specific events if event["type"] == "account.updated": account = event["data"]["object"] capabilities = account.get("capabilities", {}) @@ -143,31 +174,40 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di raise ValueError("Missing seller connect account ID") # Get the customer ID from the session - connected_customer_id = session["metadata"].get("connected_customer_id") + connected_customer_id = session["customer"] - # Retrieve SetupIntent with expanded payment method - setup_intent = stripe.SetupIntent.retrieve( - session["setup_intent"], - expand=["payment_method"], - stripe_account=seller_connect_account_id, - ) + # Check for existing payment method + existing_payment_method_id = session["metadata"].get("existing_payment_method_id") - if not setup_intent.payment_method or isinstance(setup_intent.payment_method, str): - raise ValueError("Invalid payment method") + if existing_payment_method_id: + # Use existing payment method + payment_method_id = existing_payment_method_id + else: + # Retrieve SetupIntent with expanded payment method + setup_intent = stripe.SetupIntent.retrieve( + session["setup_intent"], + expand=["payment_method"], + stripe_account=seller_connect_account_id, + ) - # Attach the payment method to the customer - stripe.PaymentMethod.attach( - setup_intent.payment_method.id, - customer=connected_customer_id, - stripe_account=seller_connect_account_id, - ) + if not setup_intent.payment_method or isinstance(setup_intent.payment_method, str): + raise ValueError("Invalid payment method") - # Set as default payment method for the customer - stripe.Customer.modify( - connected_customer_id, - invoice_settings={"default_payment_method": setup_intent.payment_method.id}, - stripe_account=seller_connect_account_id, - ) + # Attach the payment method to the customer + stripe.PaymentMethod.attach( + setup_intent.payment_method.id, + customer=connected_customer_id, + stripe_account=seller_connect_account_id, + ) + + # Set as default payment method for the customer + stripe.Customer.modify( + connected_customer_id, + invoice_settings={"default_payment_method": setup_intent.payment_method.id}, + stripe_account=seller_connect_account_id, + ) + + payment_method_id = setup_intent.payment_method.id # Create order for preorder order_data = { @@ -177,11 +217,11 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di "stripe_payment_intent_id": None, "amount": int(session["metadata"]["price_amount"]), "currency": "usd", - "status": "processing", + "status": "preorder_placed", "quantity": 1, "stripe_product_id": session["metadata"].get("stripe_product_id"), "stripe_customer_id": connected_customer_id, - "stripe_payment_method_id": setup_intent.payment_method.id, + "stripe_payment_method_id": payment_method_id, "stripe_connect_account_id": seller_connect_account_id, } @@ -204,7 +244,7 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di logger.info( "Successfully processed preorder setup for customer %s with payment method %s", connected_customer_id, - setup_intent.payment_method.id, + payment_method_id, ) except Exception as e: @@ -214,16 +254,10 @@ async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Di else: # Handle regular checkout completion await handle_checkout_session_completed(session, crud) - elif event["type"] == "payment_intent.succeeded": - payment_intent = event["data"]["object"] - logger.info("Payment intent succeeded: %s", payment_intent["id"]) return {"status": "success"} - except ValueError as e: - logger.error("Invalid payload: %s", str(e)) - raise HTTPException(status_code=400, detail="Invalid payload") except Exception as e: - logger.error("Error processing webhook: %s", str(e)) + logger.error("Error processing Connect webhook: %s", str(e)) raise HTTPException(status_code=500, detail=str(e)) @@ -365,30 +399,46 @@ async def create_checkout_session( await crud.update_user(user.id, {"stripe_customer_ids": stripe_customer_ids}) user.stripe_customer_ids = stripe_customer_ids + # Fetch existing payment methods if available + existing_payment_methods = [] + try: + payment_methods = stripe.PaymentMethod.list( + customer=connected_customer_id, + type="card", + stripe_account=seller.stripe_connect_account_id, + ) + existing_payment_methods = [pm.id for pm in payment_methods.data] + logger.info(f"Found {len(existing_payment_methods)} existing payment methods") + except Exception as e: + logger.warning(f"Error fetching customer payment methods: {str(e)}") + # Calculate maximum quantity max_quantity = 10 if listing.inventory_type == "finite" and listing.inventory_quantity is not None: max_quantity = min(listing.inventory_quantity, 10) - # Determine payment methods based on listing type and price - payment_methods: list[str] = ["card"] + # Determine allowed payment method types + allowed_payment_types: list[str] = ["card"] if listing.price_amount >= 5000 and listing.inventory_type != "preorder": - payment_methods.append("affirm") + allowed_payment_types.append("affirm") + + metadata: dict[str, str] = { + "user_email": user.email, + "product_id": listing.stripe_product_id or "", + "listing_type": listing.inventory_type, + } + + if existing_payment_methods: + metadata["existing_payment_method_id"] = existing_payment_methods[0] if listing.stripe_product_id: - metadata: dict[str, str] = { - "user_email": user.email, - "product_id": listing.stripe_product_id, - "listing_type": listing.inventory_type, - "stripe_product_id": listing.stripe_product_id, - "connected_customer_id": connected_customer_id, - } + metadata["stripe_product_id"] = listing.stripe_product_id # Base checkout session parameters checkout_params: dict[str, Any] = { "mode": "payment", - "customer_email": user.email, - "payment_method_types": payment_methods, + "customer": connected_customer_id, + "payment_method_types": allowed_payment_types, "success_url": f"{settings.site.homepage}/order/success?session_id={{CHECKOUT_SESSION_ID}}", "cancel_url": f"{settings.site.homepage}{request.cancel_url}", "client_reference_id": user.id, @@ -396,6 +446,13 @@ async def create_checkout_session( "metadata": metadata, } + # Add payment intent data to save the payment method if it's new + if not existing_payment_methods: + checkout_params["payment_intent_data"] = { + **checkout_params.get("payment_intent_data", {}), + "setup_future_usage": "off_session", + } + # For preorders, use setup mode if listing.inventory_type == "preorder": checkout_params.update( @@ -461,9 +518,6 @@ async def create_checkout_session( ], "payment_intent_data": { "application_fee_amount": application_fee, - "transfer_data": { - "destination": seller.stripe_connect_account_id, - }, }, } ) @@ -689,8 +743,8 @@ async def create_stripe_product( @router.post("/process-preorder/{order_id}") async def process_preorder( order_id: str, + crud: Annotated[Crud, Depends(Crud.get)], user: User = Depends(get_session_user_with_admin_permission), - crud: Crud = Depends(), ) -> Dict[str, Any]: async with crud: try: diff --git a/store/settings/environment.py b/store/settings/environment.py index 90990845..17f3fe80 100644 --- a/store/settings/environment.py +++ b/store/settings/environment.py @@ -69,6 +69,7 @@ class StripeSettings: publishable_key: str = field(default=II("oc.env:VITE_STRIPE_PUBLISHABLE_KEY")) secret_key: str = field(default=II("oc.env:STRIPE_SECRET_KEY")) webhook_secret: str = field(default=II("oc.env:STRIPE_WEBHOOK_SECRET")) + connect_webhook_secret: str = field(default=II("oc.env:STRIPE_CONNECT_WEBHOOK_SECRET")) @dataclass