From f7bdb1a56535a9e4137e6ffaf35d42d8de3fbe6a Mon Sep 17 00:00:00 2001
From: Amaury <1293565+amaury1729@users.noreply.github.com>
Date: Sat, 30 Dec 2023 11:40:30 +0100
Subject: [PATCH] fix: Add Stripe webhook back to pages router
---
next-env.d.ts | 1 +
src/app/api/stripe/webhooks/route.ts | 112 --------
.../webhooks => pages/api/stripe}/license.ts | 0
src/pages/api/stripe/webhooks.ts | 245 ++++++++++++++++++
4 files changed, 246 insertions(+), 112 deletions(-)
delete mode 100644 src/app/api/stripe/webhooks/route.ts
rename src/{app/api/stripe/webhooks => pages/api/stripe}/license.ts (100%)
create mode 100644 src/pages/api/stripe/webhooks.ts
diff --git a/next-env.d.ts b/next-env.d.ts
index 4f11a03d..fd36f949 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/src/app/api/stripe/webhooks/route.ts b/src/app/api/stripe/webhooks/route.ts
deleted file mode 100644
index ae1057ae..00000000
--- a/src/app/api/stripe/webhooks/route.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copied from:
-// https://github.com/vercel/nextjs-subscription-payments/blob/c7867b2d9e08d033056293d12aeb9825b8331806/app/api/webhooks/route.ts
-// License: MIT
-
-import Stripe from "stripe";
-import { stripe } from "@/util/stripeServer";
-import {
- upsertProductRecord,
- upsertPriceRecord,
- manageSubscriptionStatusChange,
-} from "@/supabase/supabaseAdmin";
-import { sendLicenseEmail } from "./license";
-import { sentryException } from "@/util/sentry";
-import { NextApiRequest } from "next";
-import type { Readable } from "node:stream";
-
-const relevantEvents = new Set([
- "product.created",
- "product.updated",
- "price.created",
- "price.updated",
- "checkout.session.completed",
- "customer.subscription.created",
- "customer.subscription.updated",
- "customer.subscription.deleted",
-]);
-
-// Stripe requires the raw body to construct the event.
-export const config = {
- api: {
- bodyParser: false,
- },
-};
-
-async function buffer(readable: Readable) {
- const chunks = [];
- for await (const chunk of readable) {
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
- }
- return Buffer.concat(chunks);
-}
-
-export async function POST(req: NextApiRequest) {
- let event: Stripe.Event;
- try {
- const buf = await buffer(req);
- const sig = req.headers["stripe-signature"] as string;
- const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
-
- event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
- } catch (err) {
- console.log(`❌ Error message: ${(err as Error).message}`);
- return new Response(`Webhook Error: ${(err as Error).message}`, {
- status: 400,
- });
- }
-
- if (relevantEvents.has(event.type)) {
- try {
- switch (event.type) {
- case "product.created":
- case "product.updated":
- await upsertProductRecord(
- event.data.object as Stripe.Product
- );
- break;
- case "price.created":
- case "price.updated":
- await upsertPriceRecord(event.data.object as Stripe.Price);
- break;
- case "customer.subscription.created":
- case "customer.subscription.updated":
- case "customer.subscription.deleted":
- const subscription = event.data
- .object as Stripe.Subscription;
- await manageSubscriptionStatusChange(
- subscription.id,
- subscription.customer as string,
- event.type === "customer.subscription.created"
- );
- break;
- case "checkout.session.completed":
- const checkoutSession = event.data
- .object as Stripe.Checkout.Session;
- if (checkoutSession.mode === "subscription") {
- const subscriptionId = checkoutSession.subscription;
- await manageSubscriptionStatusChange(
- subscriptionId as string,
- checkoutSession.customer as string,
- true
- );
- }
- break;
-
- case "invoice.payment_succeeded":
- await sendLicenseEmail(event.data.object as Stripe.Invoice);
- break;
- default:
- throw new Error("Unhandled relevant event!");
- }
- } catch (error) {
- sentryException(error as Error);
- return new Response(
- "Webhook handler failed. View your nextjs function logs.",
- {
- status: 400,
- }
- );
- }
- }
- return new Response(JSON.stringify({ received: true }));
-}
diff --git a/src/app/api/stripe/webhooks/license.ts b/src/pages/api/stripe/license.ts
similarity index 100%
rename from src/app/api/stripe/webhooks/license.ts
rename to src/pages/api/stripe/license.ts
diff --git a/src/pages/api/stripe/webhooks.ts b/src/pages/api/stripe/webhooks.ts
new file mode 100644
index 00000000..d96b1351
--- /dev/null
+++ b/src/pages/api/stripe/webhooks.ts
@@ -0,0 +1,245 @@
+import { addMonths, format } from "date-fns";
+import mailgun from "mailgun-js";
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import Attachment from "mailgun-js/lib/attachment";
+import { NextApiRequest, NextApiResponse } from "next";
+import Stripe from "stripe";
+
+import { generateLicense } from "@/util/license";
+import { sentryException } from "@/util/sentry";
+import { stripe } from "@/util/stripeServer";
+import {
+ manageSubscriptionStatusChange,
+ upsertPriceRecord,
+ upsertProductRecord,
+} from "@/supabase/supabaseAdmin";
+
+// Stripe requires the raw body to construct the event.
+export const config = {
+ api: {
+ bodyParser: false,
+ },
+};
+
+async function buffer(readable: NextApiRequest) {
+ const chunks = [];
+ for await (const chunk of readable) {
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
+ }
+ return Buffer.concat(chunks);
+}
+
+const relevantEvents = new Set([
+ "product.created",
+ "product.updated",
+ "price.created",
+ "price.updated",
+ "checkout.session.completed",
+ "customer.subscription.created",
+ "customer.subscription.updated",
+ "customer.subscription.deleted",
+ "invoice.payment_succeeded",
+]);
+
+const webhookHandler = async (
+ req: NextApiRequest,
+ res: NextApiResponse
+): Promise => {
+ if (req.method === "POST") {
+ const buf = await buffer(req);
+ const sig = req.headers["stripe-signature"] as string;
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_LIVE as string;
+ let event;
+
+ try {
+ event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
+ } catch (err) {
+ sentryException(err as Error);
+ return res
+ .status(400)
+ .send(`Webhook Error: ${(err as Error).message}`);
+ }
+
+ if (relevantEvents.has(event.type)) {
+ try {
+ switch (event.type) {
+ case "product.created":
+ case "product.updated": {
+ await upsertProductRecord(
+ event.data.object as Stripe.Product
+ );
+ break;
+ }
+
+ case "price.created":
+ case "price.updated": {
+ await upsertPriceRecord(
+ event.data.object as Stripe.Price
+ );
+ break;
+ }
+
+ case "customer.subscription.created":
+ case "customer.subscription.updated":
+ case "customer.subscription.deleted": {
+ const sub = event.data.object as Stripe.Subscription;
+ await manageSubscriptionStatusChange(
+ sub.id,
+ sub.customer as string,
+ event.type === "customer.subscription.created"
+ );
+ break;
+ }
+
+ case "checkout.session.completed": {
+ const checkoutSession = event.data
+ .object as Stripe.Checkout.Session;
+
+ if (checkoutSession.mode === "subscription") {
+ const subscriptionId = checkoutSession.subscription;
+ if (typeof subscriptionId !== "string") {
+ throw new Error(
+ `Got invalid subscriptionId in webhookHandler.`
+ );
+ }
+ if (typeof checkoutSession.customer !== "string") {
+ throw new Error(
+ `Got invalid checkoutSession.customer in webhookHandler.`
+ );
+ }
+
+ await manageSubscriptionStatusChange(
+ subscriptionId,
+ checkoutSession.customer,
+ true
+ );
+ }
+ break;
+ }
+
+ case "invoice.payment_succeeded": {
+ const invoice = event.data.object as Stripe.Invoice;
+
+ if (!invoice.customer_email) {
+ res.status(400).json({
+ error: "Got empty customer_email in invoice.",
+ });
+ return;
+ }
+
+ // We only send an email to the $399/mo plan subscribers.
+ // For all other invoices, just skip.
+ if (invoice.total !== 39900) {
+ res.status(200).json({
+ skipped: true,
+ });
+ return;
+ }
+
+ if (!invoice.customer_name) {
+ throw new Error(
+ "customer_name is empty in invoice"
+ );
+ }
+
+ // Generate PDF with the given info.
+ const stripeBuyDate = new Date(invoice.created * 1000);
+ const licenseEndDate = addMonths(stripeBuyDate, 1);
+ const pdf = await generateLicense({
+ backend_version: "<=0.3.x",
+ ciee_version: "<=0.8.x",
+ license_end_date: licenseEndDate,
+ number_devs: 8,
+ stripe_buy_date: stripeBuyDate,
+ stripe_buyer_name: invoice.customer_name,
+ stripe_buyer_email: invoice.customer_email,
+ stripe_buyer_address: stripeAddressToString(
+ invoice.customer_address
+ ),
+ });
+
+ // Send the email with the attached PDF.
+ const data = {
+ from: "Amaury ",
+ to: "amaury@reacher.email",
+ subject: `Reacher Commercial License: ${format(
+ stripeBuyDate,
+ "dd/MM/yyyy"
+ )} to ${format(licenseEndDate, "dd/MM/yyyy")}`,
+ text: `Hello ${invoice.customer_name},
+
+Thank you for using Reacher. You will find attached the Commercial License for the period of ${format(
+ stripeBuyDate,
+ "dd/MM/yyyy"
+ )} to ${format(licenseEndDate, "dd/MM/yyyy")}.
+
+A self-host guide can be found at https://help.reacher.email/self-host-guide, let me know if you need help.
+
+KR,
+Amaury`,
+ // eslint-disable-next-line
+ attachment: new Attachment({
+ ...pdf,
+ contentType: "application/pdf",
+ }),
+ };
+
+ const mg = mailgun({
+ apiKey: process.env.MAILGUN_API_KEY as string,
+ domain: process.env.MAILGUN_DOMAIN as string,
+ // We need to set Host for EU zones.
+ // https://stackoverflow.com/questions/63489555/mailgun-401-forbidden
+ host: "api.eu.mailgun.net",
+ });
+
+ await mg.messages().send(data);
+
+ break;
+ }
+
+ default:
+ throw new Error("Unhandled relevant event!");
+ }
+ } catch (err) {
+ sentryException(err as Error);
+ return res.json({
+ error: "Webhook handler failed. View logs.",
+ });
+ }
+ }
+
+ res.json({ received: true });
+ } else {
+ res.setHeader("Allow", "POST");
+ res.status(405).json({ error: "Method Not Allowed" });
+ }
+};
+
+/**
+ * Convert a Stripe.Address object to string representing the address.
+ */
+function stripeAddressToString(addr: Stripe.Address | null): string {
+ if (
+ !addr ||
+ !addr.line1 ||
+ !addr.postal_code ||
+ !addr.city ||
+ !addr.country
+ ) {
+ throw new Error(`Got invalid address: ${JSON.stringify(addr)}`);
+ }
+
+ return [
+ addr.line1,
+ addr.line2,
+ addr.postal_code,
+ addr.city,
+ addr.state,
+ addr.country,
+ ]
+ .filter((x) => !!x)
+ .join(", ");
+}
+
+export default webhookHandler;