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;