From 488dd950f2d35e9b3e5a7d3297200e888abf5213 Mon Sep 17 00:00:00 2001 From: Mia Ngo <ngo.gh98@gmail.com> Date: Mon, 28 Oct 2024 21:37:09 -0700 Subject: [PATCH] Add Stripe Tax, Paid Shipping, and Cancel Order (#510) * Stripe Integration: Add Tax * add another shipping option * basic cancel functionality * add missing CancelOrderModal file * fix static checks failures * fix lint errors * only allow modify order before shipping, and cancel reason dropdown menu * address Ben's comments and revert back to use production productId --------- Co-authored-by: Mia Ngo <miango@MacBook-Pro-2.lan> --- .../components/modals/CancelOrderModal.tsx | 167 ++++++++++++++++++ frontend/src/components/orders/OrderCard.tsx | 21 +++ frontend/src/gen/api.ts | 69 ++++++++ store/app/model.py | 3 + store/app/routers/stripe.py | 74 +++++++- 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/modals/CancelOrderModal.tsx diff --git a/frontend/src/components/modals/CancelOrderModal.tsx b/frontend/src/components/modals/CancelOrderModal.tsx new file mode 100644 index 00000000..b8b793a2 --- /dev/null +++ b/frontend/src/components/modals/CancelOrderModal.tsx @@ -0,0 +1,167 @@ +import React, { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { paths } from "@/gen/api"; +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; + +type Order = + paths["/orders/get_user_orders"]["get"]["responses"][200]["content"]["application/json"][0]; + +interface CancelOrderModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + order: Order; + onOrderUpdate: (updatedOrder: Order) => void; +} + +const CancelOrderModal: React.FC<CancelOrderModalProps> = ({ + isOpen, + onOpenChange, + order, + onOrderUpdate, +}) => { + const { client } = useAuthentication(); + const { addAlert, addErrorAlert } = useAlertQueue(); + + const cancellationReasons = [ + "Found a better price", + "Change of mind", + "Item no longer needed", + "Ordered by mistake", + "Other", + ]; + + const MAX_REASON_LENGTH = 500; + + const [cancellation, setCancellation] = useState({ + payment_intent_id: order["stripe_payment_intent_id"], + cancel_reason: { reason: "", details: "" }, + amount: order["amount"], + }); + const [customReason, setCustomReason] = useState(""); + + const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => { + const { value } = e.target; + setCancellation((prev) => ({ + ...prev, + cancel_reason: { + reason: value, + details: value === "Other" ? customReason : "", + }, + })); + }; + + const handleCustomReasonChange = (e: React.ChangeEvent<HTMLInputElement>) => { + let { value } = e.target; + + // Limit the input length + if (value.length > MAX_REASON_LENGTH) { + value = value.slice(0, MAX_REASON_LENGTH); + } + + setCustomReason(value); + setCancellation((prev) => ({ + ...prev, + cancel_reason: { + ...prev.cancel_reason, + details: value, + }, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const { data, error } = await client.PUT("/stripe/refunds/{order_id}", { + params: { path: { order_id: order.id } }, + body: cancellation, + }); + + if (error) { + addErrorAlert("Failed to cancel the order"); + console.error("Error canceling order:", error); + } else { + addAlert("Order successfully canceled", "success"); + onOrderUpdate(data); + } + + onOpenChange(false); + } catch (error) { + console.error("Error canceling order:", error); + } + }; + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[425px] bg-gray-1 text-gray-12 border border-gray-3 rounded-lg shadow-lg"> + <DialogHeader> + <DialogTitle>Cancel Order</DialogTitle> + </DialogHeader> + <form onSubmit={handleSubmit}> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="cancel_reason">Cancel Reason</Label> + <select + id="cancel_reason" + name="cancel_reason" + value={cancellation.cancel_reason.reason} + onChange={handleSelectChange} + className="bg-gray-2 border-gray-3 text-gray-12 rounded-md p-2" + > + <option value="" disabled> + Select a reason for cancellation + </option> + {cancellationReasons.map((reason) => ( + <option key={reason} value={reason}> + {reason} + </option> + ))} + </select> + {cancellation.cancel_reason.reason === "Other" && ( + <div className="mt-2"> + <input + type="text" + value={customReason} + onChange={handleCustomReasonChange} + placeholder="Please specify" + maxLength={MAX_REASON_LENGTH} + className="bg-gray-2 border-gray-3 text-gray-12 rounded-md p-2 w-full" + /> + <p className="text-sm text-gray-500 mt-1"> + {MAX_REASON_LENGTH - customReason.length} characters + remaining + </p> + </div> + )} + </div> + </div> + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button + type="submit" + className="bg-primary-9 text-gray-1 hover:bg-gray-12" + > + Save Changes + </Button> + </div> + </form> + </DialogContent> + </Dialog> + ); +}; + +export default CancelOrderModal; diff --git a/frontend/src/components/orders/OrderCard.tsx b/frontend/src/components/orders/OrderCard.tsx index 0a178103..9c5354d6 100644 --- a/frontend/src/components/orders/OrderCard.tsx +++ b/frontend/src/components/orders/OrderCard.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; +import CancelOrderModal from "@/components/modals/CancelOrderModal"; import EditAddressModal from "@/components/modals/EditAddressModal"; import { DropdownMenu, @@ -35,6 +36,7 @@ const activeStatuses = [ "shipped", ]; const redStatuses = ["cancelled", "refunded", "failed"]; +const canModifyStatuses = ["processing", "in_development", "being_assembled"]; const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ orderWithProduct: initialOrderWithProduct, @@ -44,6 +46,7 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ ); const { order, product } = orderWithProduct; const [isEditAddressModalOpen, setIsEditAddressModalOpen] = useState(false); + const [isCancelOrderModalOpen, setIsCancelOrderModalOpen] = useState(false); const currentStatusIndex = orderStatuses.indexOf(order.status); const isRedStatus = redStatuses.includes(order.status); @@ -69,6 +72,10 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ setOrderWithProduct((prev) => ({ ...prev, order: updatedOrder })); }; + const canModifyOrder = () => { + return canModifyStatuses.includes(order.status); + }; + return ( <div className="bg-white shadow-md rounded-lg p-4 md:p-6 mb-4 w-full"> <h2 className="text-gray-12 font-bold text-2xl mb-1">{product.name}</h2> @@ -87,11 +94,19 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ </DropdownMenuTrigger> <DropdownMenuContent> <DropdownMenuItem + disabled={!canModifyOrder()} onSelect={() => setIsEditAddressModalOpen(true)} className="cursor-pointer hover:bg-gray-100" > Change delivery address </DropdownMenuItem> + <DropdownMenuItem + disabled={!canModifyOrder()} + onSelect={() => setIsCancelOrderModalOpen(true)} + className="cursor-pointer hover:bg-gray-100" + > + Cancel Order + </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> @@ -202,6 +217,12 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ order={order} onOrderUpdate={handleOrderUpdate} /> + <CancelOrderModal + isOpen={isCancelOrderModalOpen} + onOpenChange={setIsCancelOrderModalOpen} + order={order} + onOrderUpdate={handleOrderUpdate} + /> </div> ); }; diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 413afdb3..9e98cc6e 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -922,6 +922,23 @@ export interface paths { patch?: never; trace?: never; }; + "/stripe/refunds/{order_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Refund Payment Intent */ + put: operations["refund_payment_intent_stripe_refunds__order_id__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/stripe/webhook": { parameters: { query?: never; @@ -1090,6 +1107,13 @@ export interface components { /** Files */ files: string[]; }; + /** CancelReason */ + CancelReason: { + /** Reason */ + reason: string; + /** Details */ + details: string; + }; /** ClientIdResponse */ ClientIdResponse: { /** Client Id */ @@ -1107,6 +1131,14 @@ export interface components { /** Session Id */ session_id: string; }; + /** CreateRefundsRequest */ + CreateRefundsRequest: { + /** Payment Intent Id */ + payment_intent_id: string; + cancel_reason: components["schemas"]["CancelReason"]; + /** Amount */ + amount: number; + }; /** DeleteTokenResponse */ DeleteTokenResponse: { /** Message */ @@ -1428,6 +1460,8 @@ export interface components { stripe_checkout_session_id: string; /** Stripe Payment Intent Id */ stripe_payment_intent_id: string; + /** Stripe Refund Id */ + stripe_refund_id?: string | null; /** Created At */ created_at: number; /** Updated At */ @@ -3348,6 +3382,41 @@ export interface operations { }; }; }; + refund_payment_intent_stripe_refunds__order_id__put: { + parameters: { + query?: never; + header?: never; + path: { + order_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRefundsRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; stripe_webhook_stripe_webhook_post: { parameters: { query?: never; diff --git a/store/app/model.py b/store/app/model.py index 3954261c..e98f05b4 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -604,6 +604,7 @@ class Order(StoreBaseModel): user_email: str stripe_checkout_session_id: str stripe_payment_intent_id: str + stripe_refund_id: str | None = None created_at: int updated_at: int status: OrderStatus @@ -629,6 +630,7 @@ def create( amount: int, currency: str, quantity: int, + stripe_refund_id: str | None = None, product_id: str | None = None, status: OrderStatus = "processing", shipping_name: str | None = None, @@ -646,6 +648,7 @@ def create( user_email=user_email, stripe_checkout_session_id=stripe_checkout_session_id, stripe_payment_intent_id=stripe_payment_intent_id, + stripe_refund_id=stripe_refund_id, created_at=now, updated_at=now, status=status, diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index efc38471..fe4b8a3b 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -8,7 +8,7 @@ from pydantic import BaseModel from store.app.db import Crud -from store.app.model import User +from store.app.model import Order, User from store.app.routers.users import get_session_user_with_read_permission from store.settings import settings @@ -40,6 +40,66 @@ async def create_payment_intent(request: Request) -> Dict[str, Any]: return {"error": str(e)} +class CancelReason(BaseModel): + reason: str + details: str + + +class CreateRefundsRequest(BaseModel): + payment_intent_id: str + cancel_reason: CancelReason + amount: int + + +@stripe_router.put("/refunds/{order_id}", response_model=Order) +async def refund_payment_intent( + order_id: str, + refund_request: CreateRefundsRequest, + user: User = Depends(get_session_user_with_read_permission), + crud: Crud = Depends(), +) -> Order: + async with crud: + try: + amount = refund_request.amount + payment_intent_id = refund_request.payment_intent_id + customer_reason = ( + refund_request.cancel_reason.details + if (refund_request.cancel_reason.reason == "Other" and refund_request.cancel_reason.details) + else refund_request.cancel_reason.reason + ) + + # Create a Refund for payment_intent_id with the order amount + refund = stripe.Refund.create( + payment_intent=payment_intent_id, + amount=amount, + reason="requested_by_customer", + metadata={"customer_reason": customer_reason}, + ) + logger.info(f"Refund created: {refund.id}") + + # Make sure order exists + order = await crud.get_order(order_id) + if order is None or order.user_id != user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") + logger.info(f"Found order id: {order.id}") + + # Update order status + order_data = { + "stripe_refund_id": refund.id, + "status": ( + "refunded" if (refund.status and refund.status) == "succeeded" else (refund.status or "no status!") + ), + } + + updated_order = await crud.update_order(order_id, order_data) + + logger.info(f"Updated order with status: {refund.status}") + return updated_order + except Exception as e: + logger.error(f"Error processing refund: {str(e)}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + @stripe_router.post("/webhook") async def stripe_webhook(request: Request, crud: Crud = Depends(Crud.get)) -> Dict[str, str]: payload = await request.body() @@ -180,6 +240,7 @@ async def create_checkout_session( }, } ], + automatic_tax={"enabled": True}, mode="payment", success_url=f"{settings.site.homepage}/success?session_id={{CHECKOUT_SESSION_ID}}", cancel_url=f"{settings.site.homepage}{cancel_url}", @@ -203,6 +264,17 @@ async def create_checkout_session( }, }, }, + { + "shipping_rate_data": { + "type": "fixed_amount", + "fixed_amount": {"amount": 2500, "currency": "usd"}, + "display_name": "Ground - Express", + "delivery_estimate": { + "minimum": {"unit": "business_day", "value": 2}, + "maximum": {"unit": "business_day", "value": 5}, + }, + }, + }, ], )