diff --git a/.github/actions/heroku-deploy/action.yml b/.github/actions/heroku-deploy/action.yml index eec8c624..86a763f5 100644 --- a/.github/actions/heroku-deploy/action.yml +++ b/.github/actions/heroku-deploy/action.yml @@ -50,6 +50,9 @@ inputs: stripe-secret-key: description: "Stripe secret key for checkout flow" required: true + stripe-endpoint-key: + description: "Stripe endpoint key for verifying webhook calls" + required: true client-url: description: "Client base URL for Stripe checkout result redirection" required: true @@ -96,9 +99,10 @@ runs: heroku config:set MAILER_REFRESH_TOKEN="${{ inputs.mailer-refresh-token }}" -a $HEROKU_APP_NAME && \ heroku config:set STRIPE_PUBLISHABLE_TEST_KEY="${{ inputs.stripe-publishable-key }}" -a $HEROKU_APP_NAME && \ heroku config:set STRIPE_SECRET_TEST_KEY="${{ inputs.stripe-secret-key }}" -a $HEROKU_APP_NAME && \ - heroku config:set PREVIEW_DEPLOY=$([ "${{inputs.deploy-branch}}" = "main" ] && echo "false" || echo "true") -a $HEROKU_APP_NAME - heroku config:set NODE_ENV="${{ inputs.node-env }}" - heroku config:set CLIENT_URL="${{ inputs.client-url }}" + heroku config:set STRIPE_ENDPOINT_KEY="${{ inputs.stripe-endpoint-key }}" -a $HEROKU_APP_NAME && \ + heroku config:set PREVIEW_DEPLOY=$([ "${{inputs.deploy-branch}}" = "main" ] && echo "false" || echo "true") -a $HEROKU_APP_NAME && \ + heroku config:set NODE_ENV="${{ inputs.node-env }}" -a $HEROKU_APP_NAME && \ + heroku config:set CLIENT_URL="${{ inputs.client-url }}" -a $HEROKU_APP_NAME env: HEROKU_APP_NAME: "${{ inputs.heroku-app-name }}" shell: bash diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 531aaf7a..687953cb 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -43,5 +43,6 @@ jobs: mailer-refresh-token: "${{ secrets.PRODUCTION_MAILER_REFRESH_TOKEN }}" stripe-publishable-key: "${{ secrets.STRIPE_PUBLISHABLE_TEST_KEY }}" stripe-secret-key: "${{ secrets.STRIPE_SECRET_TEST_KEY }}" + stripe-endpoint-key: "${{ secrets.STRIPE_ENDPOINT_KEY }}" client-url: "${{ secrets.CLIENT_URL }}" deploy-branch: "main" diff --git a/.github/workflows/heroku-deploy-dev-ts.yml b/.github/workflows/heroku-deploy-dev-ts.yml index 9e46131b..3c048242 100644 --- a/.github/workflows/heroku-deploy-dev-ts.yml +++ b/.github/workflows/heroku-deploy-dev-ts.yml @@ -35,5 +35,6 @@ jobs: mailer-refresh-token: "${{ secrets.MAILER_REFRESH_TOKEN }}" stripe-publishable-key: "${{ secrets.STRIPE_PUBLISHABLE_TEST_KEY }}" stripe-secret-key: "${{ secrets.STRIPE_SECRET_TEST_KEY }}" + stripe-endpoint-key: "${{ secrets.STRIPE_ENDPOINT_KEY }}" client-url: "${{ secrets.CLIENT_URL }}" deploy-branch: "dev" diff --git a/backend/typescript/rest/camperRoutes.ts b/backend/typescript/rest/camperRoutes.ts index 44ea631a..2bb2f139 100644 --- a/backend/typescript/rest/camperRoutes.ts +++ b/backend/typescript/rest/camperRoutes.ts @@ -18,6 +18,10 @@ import { WaitlistedCamperDTO, } from "../types"; import { createWaitlistedCampersDtoValidator } from "../middlewares/validators/waitlistedCampersValidators"; +import { + stripeKey, + verifyStripeWebhooksRequest, +} from "../utilities/stripeUtils"; const camperRouter: Router = Router(); @@ -115,16 +119,32 @@ camperRouter.get("/:chargeId/:sessionId", async (req, res) => { }); // ROLES: unprotected -/* On successful payment, mark camper as paid */ -camperRouter.post("/confirm-payment/:chargeId", async (req, res) => { - const { chargeId } = req.params; +/* Initiated by Stripe webhook. On successful payment, mark camper as paid. */ +camperRouter.post("/confirm-payment", async (req, res) => { try { - const camper = await camperService.confirmCamperPayment( - (chargeId as unknown) as string, + const event = verifyStripeWebhooksRequest( + req.headers["stripe-signature"], + req.body, ); - res.status(200).json(camper); + + if (!event) { + res.status(400).send("Webhook signature verification failed"); + } + + if (event.type === "checkout.session.completed") { + const chargeId = event.data.object.id; + + if (event.data.object.payment_status === "paid") { + await camperService.confirmCamperPayment( + (chargeId as unknown) as string, + ); + } + } + + res.status(200).json(); } catch (error: unknown) { - res.status(500).json({ error: getErrorMessage(error) }); + // Stripe requires that 200 response sent + res.status(200).json({ error: getErrorMessage(error) }); } }); diff --git a/backend/typescript/services/implementations/camperService.ts b/backend/typescript/services/implementations/camperService.ts index cd3dbd51..82c6dbde 100644 --- a/backend/typescript/services/implementations/camperService.ts +++ b/backend/typescript/services/implementations/camperService.ts @@ -24,7 +24,6 @@ import EmailService from "./emailService"; import { createStripeCheckoutSession, createStripeLineItems, - retrieveStripeCheckoutSession, } from "../../utilities/stripeUtils"; import { getEDUnits, getLPUnits } from "../../utilities/CampUtils"; @@ -246,13 +245,6 @@ class CamperService implements ICamperService { }), ); - // Email the parent about all the campers and sessions they have signed up for - await emailService.sendParentConfirmationEmail( - camp, - registeredCampers, - sessionsToRegister, - ); - // Send admin an email for all the sessions that are now full // Note: At this point, the sessionsToRegister's campers field has been updated with the registered campers const fullSessions = sessionsToRegister.filter( @@ -495,17 +487,6 @@ class CamperService implements ICamperService { session.startTransaction(); try { - const checkoutSession = await retrieveStripeCheckoutSession(chargeId); - if (!checkoutSession) { - throw new Error(`Could not find checkout session with id ${chargeId}`); - } - - if (checkoutSession.payment_status !== "paid") { - throw new Error( - `Checkout session status is ${checkoutSession.payment_status}, expected status to be "paid"`, - ); - } - const campers = await MgCamper.find({ chargeId }); if (!campers || campers.length === 0) { throw new Error( @@ -518,6 +499,36 @@ class CamperService implements ICamperService { { $set: { hasPaid: true } }, { session, runValidators: true }, ); + + const campSessionIds = Array.from( + new Set(campers.map((camper: Camper) => camper.campSession)), + ); + + const campSessions: Array = await MgCampSession.find({ + _id: { $in: campSessionIds }, + }); + + if (!campSessions || campSessions.length === 0) { + throw new Error( + `Could not find camp session(s) associated with objects in checkout session with id ${chargeId}`, + ); + } + + const camp: Camp | null = await MgCamp.findById(campSessions[0].camp); + + if (!camp) { + throw new Error( + `Could not find camp associated with objects in checkout session with id ${chargeId}`, + ); + } + + // Email the parent about all the campers and sessions they have signed up for + await emailService.sendParentConfirmationEmail( + camp, + campers, + campSessions, + ); + await session.commitTransaction(); } catch (error: unknown) { await session.abortTransaction(); diff --git a/backend/typescript/utilities/stripeUtils.ts b/backend/typescript/utilities/stripeUtils.ts index 4dd50159..0cb812ac 100644 --- a/backend/typescript/utilities/stripeUtils.ts +++ b/backend/typescript/utilities/stripeUtils.ts @@ -8,6 +8,8 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_TEST_KEY ?? "", { apiVersion: "2020-08-27", }); +const STRIPE_ENDPOINT_KEY = process.env.STRIPE_ENDPOINT_SECRET || ""; + const dropoffProductName = "Early Drop Off Fees"; const pickupProductName = "Late Pick Up Fees"; @@ -147,8 +149,21 @@ export async function createStripeCheckoutSession( return checkoutSession; } -export async function retrieveStripeCheckoutSession( - chargeId: string, -): Promise> { - return stripe.checkout.sessions.retrieve(chargeId); -} +// Similar to Stripe example: +// https://github.com/stripe/stripe-node/blob/master/examples/webhook-signing/typescript-node-express/express-ts.ts +export const verifyStripeWebhooksRequest = ( + signature: any, + body: any, +): Stripe.Event | undefined => { + try { + const event: Stripe.Event = stripe.webhooks.constructEvent( + body, + signature, + STRIPE_ENDPOINT_KEY, + ); + return event; + } catch (err: any) { + console.log(`❌ Error message: ${err.message}`); + return undefined; + } +}; diff --git a/frontend/src/components/pages/RegistrantExperience/RegistrationResult/index.tsx b/frontend/src/components/pages/RegistrantExperience/RegistrationResult/index.tsx index ba25f1b1..603034f7 100644 --- a/frontend/src/components/pages/RegistrantExperience/RegistrationResult/index.tsx +++ b/frontend/src/components/pages/RegistrantExperience/RegistrationResult/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React from "react"; import { Text, Flex, VStack, HStack } from "@chakra-ui/react"; import { CartItem, EdlpChoice } from "../../../../types/RegistrationTypes"; @@ -16,7 +16,6 @@ import { } from "./textStyles"; import { CampResponse, CampSession } from "../../../../types/CampsTypes"; import { RegistrantExperienceCamper } from "../../../../types/CamperTypes"; -import CamperAPIClient from "../../../../APIClients/CamperAPIClient"; const NoSessionDataFound = (): React.ReactElement => { return ( @@ -73,12 +72,6 @@ const RegistrationResult = ({ edlpChoices, chargeId, }: RegistrationResultProps): React.ReactElement => { - useEffect(() => { - if (chargeId) { - CamperAPIClient.confirmPayment(chargeId); - } - }, [chargeId]); - return ( { - const getCampSessionDates = () => { - const startDate = new Date(campSession.dates[0]); - const endDate = new Date(campSession.dates[campSession.dates.length - 1]); - return `${startDate.toLocaleString("default", { - month: "short", - })} ${startDate.getDay()} - ${endDate.toLocaleString("default", { - month: "short", - })} ${endDate.getDay()}, ${endDate.getFullYear()}`; + const getCampSessionDates = (): string => { + if (campSession.dates.length > 1) { + const startDate = new Date(campSession.dates[0]); + const endDate = new Date(campSession.dates[campSession.dates.length - 1]); + return `${startDate.toLocaleDateString("en-us", { + month: "short", + })} ${startDate.getDate()} - ${endDate.toLocaleDateString("en-us", { + month: "short", + })} ${endDate.getDate()}, ${endDate.getFullYear()}`; + } + + if (campSession.dates.length === 1) { + const date = new Date(campSession.dates[0]); + return `${date.toLocaleDateString("en-us", { + month: "short", + })} ${date.getDate()}, ${date.getFullYear()}`; + } + + return ""; }; const formattedTimeString = (start: string, end: string): string => { @@ -66,7 +77,7 @@ type SessionCardProps = { fee: number; startTime: string; endTime: string; - campSession: CampSessionResponse; + campSession: CampSession; handleClick: (sessionID: string) => void; state: SessionCardState; sessionIsWaitlisted: boolean;