Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/docs-new/content/docs/schools/set-up-payments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ CourseLit offers integrations with the following payment platforms:
- In the destination type, select `Webhook endpoint`.
- In the destination, enter your CourseLit school's webhook endpoint (listed in the same payment screen in your school).
![Stripe webhook destination](/assets/schools/stripe-courselit-webhook-entry.png)
9. That's it! Your Stripe configuration is complete, and you are ready to receive payments.
9. Copy the webhook signing secret from Stripe and paste it into `Stripe Webhook Secret` in your CourseLit payment settings.
10. That's it! Your Stripe configuration is complete, and you are ready to receive payments.

## Razorpay setup

Expand Down
3 changes: 2 additions & 1 deletion apps/docs/src/pages/en/schools/set-up-payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ CourseLit offers integrations with the following payment platforms:
- In the destination type, select `Webhook endpoint`.
- In the destination, enter your CourseLit school's webhook endpoint (listed in the same payment screen in your school).
![Stripe webhook destination](/assets/schools/stripe-courselit-webhook-entry.png)
9. That's it! Your Stripe configuration is complete, and you are ready to receive payments.
9. Copy the webhook signing secret from Stripe and paste it into `Stripe Webhook Secret` in your CourseLit payment settings.
10. That's it! Your Stripe configuration is complete, and you are ready to receive payments.

## Razorpay setup

Expand Down
86 changes: 86 additions & 0 deletions apps/web/app/api/payment/__tests__/stripe-payment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @jest-environment node
*/

import StripePayment from "../../../../payments-new/stripe-payment";

const siteInfo = {
currencyISOCode: "usd",
stripeKey: "pk_test_123",
stripeSecret: "sk_test_123",
stripeWebhookSecret: "whsec_test_123",
};

const checkoutSessionCompleted = {
id: "evt_test",
object: "event",
type: "checkout.session.completed",
data: {
object: {
id: "cs_test_123",
payment_status: "paid",
metadata: {
membershipId: "membership_123",
invoiceId: "invoice_123",
},
},
},
};

describe("StripePayment webhook verification", () => {
it("accepts Stripe events with a valid signature over the raw body", async () => {
const payment = (await new StripePayment(siteInfo).setup()) as any;
const rawBody = JSON.stringify(checkoutSessionCompleted);
const signature = payment.stripe.webhooks.generateTestHeaderString({
payload: rawBody,
secret: siteInfo.stripeWebhookSecret,
});

await expect(
payment.verify(checkoutSessionCompleted, {
rawBody,
signature,
}),
).resolves.toBe(true);
});

it("rejects events when the Stripe signature is missing", async () => {
const payment = await new StripePayment(siteInfo).setup();
const rawBody = JSON.stringify(checkoutSessionCompleted);

await expect(
payment.verify(checkoutSessionCompleted, {
rawBody,
signature: null,
}),
).resolves.toBe(false);
});

it("rejects events when the raw body has been changed", async () => {
const payment = (await new StripePayment(siteInfo).setup()) as any;
const rawBody = JSON.stringify(checkoutSessionCompleted);
const signature = payment.stripe.webhooks.generateTestHeaderString({
payload: rawBody,
secret: siteInfo.stripeWebhookSecret,
});

await expect(
payment.verify(checkoutSessionCompleted, {
rawBody: JSON.stringify({
...checkoutSessionCompleted,
id: "evt_tampered",
}),
signature,
}),
).resolves.toBe(false);
});

it("requires a Stripe webhook secret during setup", async () => {
await expect(
new StripePayment({
...siteInfo,
stripeWebhookSecret: undefined,
}).setup(),
).rejects.toThrow("stripe");
});
});
25 changes: 20 additions & 5 deletions apps/web/app/api/payment/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { activateMembership } from "../helpers";

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const rawBody = await req.text();
const body = JSON.parse(rawBody);
const domainName = req.headers.get("domain");

const domain = await getDomain(domainName);
Expand All @@ -30,19 +31,33 @@ export async function POST(req: NextRequest) {

const paymentMethod = await getPaymentMethod(domain._id.toString());
if (!paymentMethod) {
return Response.json({ message: "Payment method not found" });
return Response.json(
{ message: "Payment method not found" },
{ status: 404 },
);
}

if (!(await paymentMethod.verify(body))) {
return Response.json({ message: "Payment not verified" });
if (
!(await paymentMethod.verify(body, {
rawBody,
signature: req.headers.get("stripe-signature"),
}))
) {
return Response.json(
{ message: "Payment not verified" },
{ status: 400 },
);
}

const metadata = paymentMethod.getMetadata(body);
const { membershipId, invoiceId, currencyISOCode } = metadata;

const membership = await getMembership(domain._id, membershipId);
if (!membership) {
return Response.json({ message: "Membership not found" });
return Response.json(
{ message: "Membership not found" },
{ status: 404 },
);
}

const paymentPlan = await getPaymentPlan(
Expand Down
20 changes: 20 additions & 0 deletions apps/web/components/admin/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SITE_SETTINGS_PAGE_HEADING,
SITE_SETTINGS_CURRENCY,
SITE_ADMIN_SETTINGS_STRIPE_SECRET,
SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET,
SITE_ADMIN_SETTINGS_RAZORPAY_SECRET,
SITE_ADMIN_SETTINGS_PAYPAL_SECRET,
SITE_ADMIN_SETTINGS_PAYTM_SECRET,
Expand Down Expand Up @@ -528,6 +529,7 @@ const Settings = (props: SettingsProps) => {
$paymentMethod: String,
$stripeKey: String,
$stripeSecret: String,
$stripeWebhookSecret: String,
$razorpayKey: String,
$razorpaySecret: String,
$razorpayWebhookSecret: String,
Expand All @@ -543,6 +545,7 @@ const Settings = (props: SettingsProps) => {
paymentMethod: $paymentMethod,
stripeKey: $stripeKey,
stripeSecret: $stripeSecret,
stripeWebhookSecret: $stripeWebhookSecret,
razorpayKey: $razorpayKey,
razorpaySecret: $razorpaySecret,
razorpayWebhookSecret: $razorpayWebhookSecret,
Expand Down Expand Up @@ -592,6 +595,7 @@ const Settings = (props: SettingsProps) => {
paymentMethod: newSettings.paymentMethod,
stripeKey: newSettings.stripeKey,
stripeSecret: newSettings.stripeSecret,
stripeWebhookSecret: newSettings.stripeWebhookSecret,
razorpayKey: newSettings.razorpayKey,
razorpaySecret: newSettings.razorpaySecret,
razorpayWebhookSecret:
Expand Down Expand Up @@ -694,6 +698,9 @@ const Settings = (props: SettingsProps) => {
stripeSecret: getNewSettings
? newSettings.stripeSecret
: settings.stripeSecret,
stripeWebhookSecret: getNewSettings
? newSettings.stripeWebhookSecret
: settings.stripeWebhookSecret,
paypalSecret: getNewSettings
? newSettings.paypalSecret
: settings.paypalSecret,
Expand Down Expand Up @@ -1014,6 +1021,19 @@ const Settings = (props: SettingsProps) => {
sx={{ mb: 2 }}
autoComplete="off"
/>
<FormField
label={
SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET
}
name="stripeWebhookSecret"
type="password"
value={
newSettings.stripeWebhookSecret || ""
}
onChange={onChangeData}
sx={{ mb: 2 }}
autoComplete="off"
/>
</>
)}
{newSettings.paymentMethod ===
Expand Down
6 changes: 5 additions & 1 deletion apps/web/graphql/settings/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ export const checkForInvalidPaymentMethodSettings = (

if (
siteInfo.paymentMethod === UIConstants.PAYMENT_METHOD_STRIPE &&
!(siteInfo.stripeSecret && siteInfo.stripeKey)
!(
siteInfo.stripeSecret &&
siteInfo.stripeKey &&
siteInfo.stripeWebhookSecret
)
) {
failedPaymentMethod = UIConstants.PAYMENT_METHOD_STRIPE;
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/graphql/settings/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const getSiteInfo = async (ctx: GQLContext) => {
deleted: 0,
customDomain: 0,
"settings.stripeSecret": 0,
"settings.stripeWebhookSecret": 0,
"settings.paytmSecret": 0,
"settings.paypalSecret": 0,
"settings.razorpaySecret": 0,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/payments-new/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ interface Metadata {
export default interface Payment {
setup: () => void;
initiate: (obj: InitiateProps) => void;
verify: (event: any) => Promise<boolean>;
verify: (
event: any,
context?: { rawBody?: string; signature?: string | null },
) => Promise<boolean>;
getPaymentIdentifier: (event: any) => unknown;
getMetadata: (event: any) => Record<string, unknown>;
getName: () => string;
Expand Down
27 changes: 26 additions & 1 deletion apps/web/payments-new/stripe-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export default class StripePayment implements Payment {
throw new Error(`${this.name} ${paymentInvalidSettings}`);
}

if (!this.siteinfo.stripeWebhookSecret) {
throw new Error(`${this.name} ${paymentInvalidSettings}`);
}

this.stripe = new Stripe(this.siteinfo.stripeSecret, {
typescript: true,
});
Expand Down Expand Up @@ -77,7 +81,28 @@ export default class StripePayment implements Payment {
return this.siteinfo.currencyISOCode!;
}

async verify(event: Stripe.Event) {
async verify(
event: Stripe.Event,
context?: { rawBody?: string; signature?: string | null },
) {
if (
!context?.rawBody ||
!context.signature ||
!this.siteinfo.stripeWebhookSecret
) {
return false;
}

try {
event = this.stripe.webhooks.constructEvent(
context.rawBody,
context.signature,
this.siteinfo.stripeWebhookSecret,
);
} catch {
return false;
}

if (!event) {
return false;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/ui-config/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export const HEADER_COURSELIT = "About CourseLit";
export const MEDIA_SELECTOR_UPLOAD_BTN_CAPTION = "Upload a picture";
export const MEDIA_SELECTOR_REMOVE_BTN_CAPTION = "Remove picture";
export const SITE_ADMIN_SETTINGS_STRIPE_SECRET = "Stripe Secret Key";
export const SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET =
"Stripe Webhook Secret";
export const SITE_ADMIN_SETTINGS_RAZORPAY_SECRET = "Razorpay Secret Key";
export const SITE_ADMIN_SETTINGS_RAZORPAY_WEBHOOK_SECRET =
"Razorpay Webhook Secret";
Expand Down
1 change: 1 addition & 0 deletions packages/orm-models/src/models/site-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const SettingsSchema = new mongoose.Schema<SiteInfo>({
codeInjectionHead: { type: String },
codeInjectionBody: { type: String },
stripeSecret: { type: String },
stripeWebhookSecret: { type: String },
paytmSecret: { type: String },
paypalSecret: { type: String },
mailingAddress: { type: String },
Expand Down