Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Emma/stripe secure #251

Draft
wants to merge 2 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/actions/heroku-deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions .github/workflows/heroku-deploy-dev-ts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
34 changes: 27 additions & 7 deletions backend/typescript/rest/camperRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
WaitlistedCamperDTO,
} from "../types";
import { createWaitlistedCampersDtoValidator } from "../middlewares/validators/waitlistedCampersValidators";
import {
stripeKey,
verifyStripeWebhooksRequest,
} from "../utilities/stripeUtils";

const camperRouter: Router = Router();

Expand Down Expand Up @@ -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) });
}
});

Expand Down
49 changes: 30 additions & 19 deletions backend/typescript/services/implementations/camperService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import EmailService from "./emailService";
import {
createStripeCheckoutSession,
createStripeLineItems,
retrieveStripeCheckoutSession,
} from "../../utilities/stripeUtils";
import { getEDUnits, getLPUnits } from "../../utilities/CampUtils";

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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<CampSession> = 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();
Expand Down
25 changes: 20 additions & 5 deletions backend/typescript/utilities/stripeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -147,8 +149,21 @@ export async function createStripeCheckoutSession(
return checkoutSession;
}

export async function retrieveStripeCheckoutSession(
chargeId: string,
): Promise<Stripe.Response<Stripe.Checkout.Session>> {
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;
}
};
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
Expand Down Expand Up @@ -73,12 +72,6 @@ const RegistrationResult = ({
edlpChoices,
chargeId,
}: RegistrationResultProps): React.ReactElement => {
useEffect(() => {
if (chargeId) {
CamperAPIClient.confirmPayment(chargeId);
}
}, [chargeId]);

return (
<Flex
direction="column"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import React from "react";
import { Box, Text, Grid, GridItem, VStack } from "@chakra-ui/react";
import {
CampResponse,
CampSessionResponse,
} from "../../../../types/CampsTypes";
import { CampResponse } from "../../../../types/CampsTypes";
import {
SessionCardState,
SessionSelectionState,
Expand Down Expand Up @@ -53,7 +50,7 @@ const CampSessionBoard = ({
fee={camp.fee}
startTime={camp.startTime}
endTime={camp.endTime}
campSession={campSession as CampSessionResponse}
campSession={campSession}
handleClick={handleSessionClick}
state={getCardState(
campSession.id,
Expand All @@ -73,7 +70,7 @@ const CampSessionBoard = ({
fee={camp.fee}
startTime={camp.startTime}
endTime={camp.endTime}
campSession={campSession as CampSessionResponse}
campSession={campSession}
handleClick={handleSessionClick}
state={getCardState(
campSession.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { Box, Text, VStack, Flex, useMediaQuery } from "@chakra-ui/react";
import { CampSessionResponse } from "../../../../types/CampsTypes";
import { CampSession } from "../../../../types/CampsTypes";
import { SessionCardState } from "./SessionSelectionTypes";
import {
sessionCardBoldTextStyles,
Expand All @@ -17,7 +17,7 @@ type SessionCardDetailsProps = {
fee: number;
startTime: string;
endTime: string;
campSession: CampSessionResponse;
campSession: CampSession;
};

const SessionCardDetails = ({
Expand All @@ -26,14 +26,25 @@ const SessionCardDetails = ({
endTime,
campSession,
}: SessionCardDetailsProps): React.ReactElement => {
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 => {
Expand Down Expand Up @@ -66,7 +77,7 @@ type SessionCardProps = {
fee: number;
startTime: string;
endTime: string;
campSession: CampSessionResponse;
campSession: CampSession;
handleClick: (sessionID: string) => void;
state: SessionCardState;
sessionIsWaitlisted: boolean;
Expand Down