-
-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: Add Stripe webhook back to pages router
- Loading branch information
1 parent
0bdc4a7
commit f7bdb1a
Showing
4 changed files
with
246 additions
and
112 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
/// <reference types="next" /> | ||
/// <reference types="next/image-types/global" /> | ||
/// <reference types="next/navigation-types/compat/navigation" /> | ||
|
||
// NOTE: This file should not be edited | ||
// see https://nextjs.org/docs/basic-features/typescript for more information. |
This file was deleted.
Oops, something went wrong.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<void> => { | ||
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 <[email protected]>", | ||
to: "[email protected]", | ||
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; |