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},
+                        },
+                    },
+                },
             ],
         )