diff --git a/backend/typescript/rest/camperRoutes.ts b/backend/typescript/rest/camperRoutes.ts index 8dd4b736..65c1a8ea 100644 --- a/backend/typescript/rest/camperRoutes.ts +++ b/backend/typescript/rest/camperRoutes.ts @@ -89,6 +89,7 @@ camperRouter.get( }, ); +// ROLES: Unprotected camperRouter.get("/refund/:refundCode", async (req, res) => { const { refundCode } = req.params; try { @@ -101,6 +102,20 @@ camperRouter.get("/refund/:refundCode", async (req, res) => { } }); +// ROLES: Unprotected +// Used to contact stripe to obtain refund discount information +camperRouter.get("/refund-discount-info/:chargeId", async (req, res) => { + const { chargeId } = req.params; + try { + const discountInfo = await camperService.getRefundDiscountInfo( + (chargeId as unknown) as string, + ); + res.status(200).json(discountInfo); + } catch (error: unknown) { + res.status(500).json({ error: getErrorMessage(error) }); + } +}); + // ROLES: TODO- Leaving unprotected as parent might need this route for refund flow @dhruv camperRouter.get("/refund-confirm/:chargeId", async (req, res) => { const { chargeId } = req.params; diff --git a/backend/typescript/services/implementations/camperService.ts b/backend/typescript/services/implementations/camperService.ts index 34cb5c08..62e9f281 100644 --- a/backend/typescript/services/implementations/camperService.ts +++ b/backend/typescript/services/implementations/camperService.ts @@ -28,6 +28,7 @@ import EmailService from "./emailService"; import { createStripeCheckoutSession, createStripeLineItems, + retrieveStripeCheckoutSession, } from "../../utilities/stripeUtils"; import { getEDUnits, getLPUnits } from "../../utilities/CampUtils"; @@ -1410,6 +1411,30 @@ class CamperService implements ICamperService { throw error; } } + + async getRefundDiscountInfo(chargeId: string): Promise { + let discountAmount = 0; + + try { + const checkoutSession: Stripe.Checkout.Session = await retrieveStripeCheckoutSession( + chargeId, + ); + + if (!checkoutSession) { + throw new Error(`Could not find checkout session with id ${chargeId}`); + } + + // Stripe returns value without decimal point so divide by 100.0 to convert to float + discountAmount = checkoutSession.total_details?.amount_discount + ? checkoutSession.total_details?.amount_discount / 100.0 + : 0; + } catch (error: unknown) { + Logger.error("Failed to retrieve checkout session."); + throw error; + } + + return discountAmount; + } } export default CamperService; diff --git a/backend/typescript/services/interfaces/camperService.ts b/backend/typescript/services/interfaces/camperService.ts index feb3685d..8a1ab8c3 100644 --- a/backend/typescript/services/interfaces/camperService.ts +++ b/backend/typescript/services/interfaces/camperService.ts @@ -140,6 +140,13 @@ interface ICamperService { * @throws Error if retrieval fails */ getRefundInfo(refundCode: string): Promise; + + /** + * Returns the associated discount from coupons from a specific chargeId + * @param chargeId code specific to the checkout session + * @throws Error if retrieval fails + */ + getRefundDiscountInfo(chargeId: string): Promise; } export default ICamperService; diff --git a/frontend/src/APIClients/CamperAPIClient.ts b/frontend/src/APIClients/CamperAPIClient.ts index 20909d7c..9c34f1df 100644 --- a/frontend/src/APIClients/CamperAPIClient.ts +++ b/frontend/src/APIClients/CamperAPIClient.ts @@ -154,6 +154,20 @@ const getRefundInfo = async (refundCode: string): Promise => { } }; +const getRefundDiscountInfo = async (chargeId: string): Promise => { + try { + const { data } = await baseAPIClient.get( + `/campers/refund-discount-info/${chargeId}`, + { + headers: { Authorization: getBearerToken() }, + }, + ); + return data; + } catch (error) { + return error as number; + } +}; + const confirmPayment = async (chargeId: string): Promise => { try { const { data } = await baseAPIClient.post( @@ -180,4 +194,5 @@ export default { waitlistCampers, confirmPayment, getRefundInfo, + getRefundDiscountInfo, }; diff --git a/frontend/src/components/pages/CamperRefundCancellation/CamperRefundFooter.tsx b/frontend/src/components/pages/CamperRefundCancellation/CamperRefundFooter.tsx index a1f0f2ce..9a8d9ac5 100644 --- a/frontend/src/components/pages/CamperRefundCancellation/CamperRefundFooter.tsx +++ b/frontend/src/components/pages/CamperRefundCancellation/CamperRefundFooter.tsx @@ -1,7 +1,13 @@ import React from "react"; import { Flex, Button } from "@chakra-ui/react"; -const CamperRefundFooter = (): React.ReactElement => { +type CamperRefundFooterProps = { + isDisabled: boolean; +}; + +const CamperRefundFooter = ({ + isDisabled, +}: CamperRefundFooterProps): React.ReactElement => { return ( { variant="primary" background="primary.green.100" textStyle="buttonSemiBold" + disabled={isDisabled} py="12px" px="25px" > diff --git a/frontend/src/components/pages/CamperRefundCancellation/CamperRefundInfoCard.tsx b/frontend/src/components/pages/CamperRefundCancellation/CamperRefundInfoCard.tsx index 650abb20..12e2b17e 100644 --- a/frontend/src/components/pages/CamperRefundCancellation/CamperRefundInfoCard.tsx +++ b/frontend/src/components/pages/CamperRefundCancellation/CamperRefundInfoCard.tsx @@ -15,6 +15,11 @@ type CamperRefundInfoCardProps = { lastName: string; camperNum: number; instances: Array; + setCardsDisabled: React.Dispatch>; + checkedRefunds: Array; + setCheckedRefunds: React.Dispatch>; + refundAmountMap: Array; + setRefundAmountMap: React.Dispatch>>; }; const CamperRefundInfoCard = ({ @@ -23,6 +28,11 @@ const CamperRefundInfoCard = ({ lastName, camperNum, instances, + setCardsDisabled, + checkedRefunds, + setCheckedRefunds, + refundAmountMap, + setRefundAmountMap, }: CamperRefundInfoCardProps): React.ReactElement => { const getTimeDifference = (date1: Date, date2: Date): number => { const date1Time = date1.getHours() * 60 + date1.getMinutes(); @@ -98,7 +108,21 @@ const CamperRefundInfoCard = ({ return firstDate - today >= thirtyDays; }; + const handleCheckboxChange = (index: number) => { + const updatedCheckedRefunds = [...checkedRefunds]; + const checked = !updatedCheckedRefunds[index]; + updatedCheckedRefunds[index] = checked; + setCheckedRefunds(updatedCheckedRefunds); + + const updatedRefundAmountMap = [...refundAmountMap]; + updatedRefundAmountMap[index] = checked ? getTotalRefundForCamper() : 0; + setRefundAmountMap(updatedRefundAmountMap); + }; + const valid = isRefundValid(); + if (!valid) { + setCardsDisabled(true); + } const textColor = valid ? "#000000" : "#00000066"; @@ -122,6 +146,7 @@ const CamperRefundInfoCard = ({ handleCheckboxChange(camperNum - 1)} colorScheme="green" size="lg" > diff --git a/frontend/src/components/pages/CamperRefundCancellation/index.tsx b/frontend/src/components/pages/CamperRefundCancellation/index.tsx index 24cd1fdd..497862ed 100644 --- a/frontend/src/components/pages/CamperRefundCancellation/index.tsx +++ b/frontend/src/components/pages/CamperRefundCancellation/index.tsx @@ -9,6 +9,7 @@ import { useToast, Spinner, Center, + Divider, } from "@chakra-ui/react"; import FONIcon from "../../../assets/fon_icon.svg"; @@ -29,17 +30,41 @@ const CamperRefundCancellation = (): React.ReactElement => { const [campName, setCampName] = useState(""); const [validCode, setValidCode] = useState(false); const [loading, setLoading] = useState(true); - - // The camper-refund-cancellation route will have an id to identify the refund code + const [refundDiscountAmount, setRefundDiscountAmount] = useState(-1); + const [totalRefundAmount, setTotalRefundAmount] = useState(-1); + const [cardsDisabled, setCardsDisabled] = useState(false); + const [refundAmountMap, setRefundAmountMap] = useState>([]); + const [checkedRefunds, setCheckedRefunds] = useState>([]); const { id: refundCode } = useParams<{ id: string }>(); useEffect(() => { const getRefundInfoById = async (code: string) => { + let numberOfRefunds = 0; + let allRefunds = 0; try { - const getResponse = await CamperAPIClient.getRefundInfo(code); + const getRefunds = await CamperAPIClient.getRefundInfo(code); + numberOfRefunds = getRefunds.length; setValidCode(true); - setRefunds(getResponse); - setCampName(getResponse[0].campName); + setRefunds(getRefunds); + setCampName(getRefunds[0].campName); + const getDiscountAmount = await CamperAPIClient.getRefundDiscountInfo( + getRefunds[0].instances[0].chargeId, + ); + setRefundDiscountAmount(getDiscountAmount); + const refundAmountMapArray = Array(numberOfRefunds); + getRefunds.forEach((refund, index) => { + let charge = 0; + refund.instances.forEach((instance) => { + charge += + instance.charges.earlyDropoff + + instance.charges.latePickup + + instance.charges.camp; + allRefunds += charge; + }); + setTotalRefundAmount(allRefunds); + refundAmountMapArray[index] = charge; + }); + setRefundAmountMap(refundAmountMapArray); } catch { toast({ description: `Unable to retrieve Refund Info.`, @@ -48,24 +73,42 @@ const CamperRefundCancellation = (): React.ReactElement => { variant: "subtle", }); } finally { + const checkedRefundsArray = Array.from( + { length: numberOfRefunds }, + () => true, + ); + setCheckedRefunds(checkedRefundsArray); setLoading(false); } }; getRefundInfoById(refundCode); - }); - + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const getTotalRefund = () => { + if (cardsDisabled) { + return 0; + } let totalRefund = 0; - refunds.forEach((refund) => { - refund.instances.forEach((instance) => { - totalRefund += - instance.charges.earlyDropoff + - instance.charges.latePickup + - instance.charges.camp; - }); + refundAmountMap.forEach((refundAmount) => { + totalRefund += refundAmount; }); return totalRefund; }; + const discountPercentage = refundDiscountAmount / totalRefundAmount; + const proportionalDiscountAmount = discountPercentage * getTotalRefund(); + + const isCardChecked = (): boolean => { + let state = false; + checkedRefunds.forEach((isChecked) => { + state = state || isChecked; + }); + return state; + }; + + const isFooterButtonDisabled = (): boolean => { + // return true if disabled, false otherwise + return cardsDisabled || !isCardChecked(); + }; if (loading) { return ( @@ -78,7 +121,6 @@ const CamperRefundCancellation = (): React.ReactElement => { if (!validCode) { return ; } - return ( <> @@ -133,6 +175,21 @@ const CamperRefundCancellation = (): React.ReactElement => { + {cardsDisabled && ( + + + Note: You cannot request a refund within 30 days of the camp’s + start date. Please contact us at + + {" camps@focusonnature.ca "} + + to manually request a refund. + + + )} {refunds.map((refundObject, refundNum) => { return ( @@ -143,33 +200,74 @@ const CamperRefundCancellation = (): React.ReactElement => { instances={refundObject.instances} key={refundNum} camperNum={refundNum + 1} + setCardsDisabled={setCardsDisabled} + checkedRefunds={checkedRefunds} + setCheckedRefunds={setCheckedRefunds} + refundAmountMap={refundAmountMap} + setRefundAmountMap={setRefundAmountMap} /> ); })} - - - Total Refund - - - ${getTotalRefund()} - - + {proportionalDiscountAmount > 0 && !cardsDisabled && isCardChecked() && ( + <> + + + Refund Amount + ${getTotalRefund()} + + + Discount + + -${proportionalDiscountAmount} + + + + + + )} + + {proportionalDiscountAmount === 0 || + cardsDisabled || + !isCardChecked() ? ( + + + Total Refund + + + ${Math.max(getTotalRefund(), 0)} + + + ) : ( + + + ${getTotalRefund() - proportionalDiscountAmount} + + + )} - + ); };