From d042c82cb034389fcccac103796bb19e96427c6e Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sun, 6 Oct 2024 01:59:31 -0400 Subject: [PATCH] add subscription webhook --- lib/api/controllers/users/getUsers.ts | 2 +- lib/api/handleSubscription.ts | 91 +++++++++++ lib/api/paymentCheckout.ts | 6 + pages/api/v1/webhook/index.ts | 143 ++++++++++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 lib/api/handleSubscription.ts create mode 100644 pages/api/v1/webhook/index.ts create mode 100644 prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts index 496efcf..dd589c5 100644 --- a/lib/api/controllers/users/getUsers.ts +++ b/lib/api/controllers/users/getUsers.ts @@ -17,5 +17,5 @@ export default async function getUsers() { }, }); - return { response: users, status: 200 }; + return { response: users.sort((a: any, b: any) => a.id - b.id), status: 200 }; } diff --git a/lib/api/handleSubscription.ts b/lib/api/handleSubscription.ts new file mode 100644 index 0000000..6ccaaf3 --- /dev/null +++ b/lib/api/handleSubscription.ts @@ -0,0 +1,91 @@ +import Stripe from "stripe"; +import { prisma } from "./db"; + +type Data = { + id: string; + active: boolean; + quantity: number; + periodStart: number; + periodEnd: number; +}; + +export default async function handleSubscription({ + id, + active, + quantity, + periodStart, + periodEnd, +}: Data) { + const subscription = await prisma.subscription.findUnique({ + where: { + stripeSubscriptionId: id, + }, + }); + + if (subscription) { + await prisma.subscription.update({ + where: { + stripeSubscriptionId: id, + }, + data: { + active, + quantity, + currentPeriodStart: new Date(periodStart * 1000), + currentPeriodEnd: new Date(periodEnd * 1000), + }, + }); + return; + } else { + if (!process.env.STRIPE_SECRET_KEY) + throw new Error("Missing Stripe secret key"); + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + + const subscription = await stripe.subscriptions.retrieve(id); + const customerId = subscription.customer; + + const customer = await stripe.customers.retrieve(customerId.toString()); + const email = (customer as Stripe.Customer).email; + + if (!email) throw new Error("Email not found"); + + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + + if (!user) throw new Error("User not found"); + + const userId = user.id; + + await prisma.subscription + .upsert({ + where: { + userId, + }, + create: { + active, + stripeSubscriptionId: id, + quantity, + currentPeriodStart: new Date(periodStart * 1000), + currentPeriodEnd: new Date(periodEnd * 1000), + user: { + connect: { + id: userId, + }, + }, + }, + update: { + active, + stripeSubscriptionId: id, + quantity, + currentPeriodStart: new Date(periodStart * 1000), + currentPeriodEnd: new Date(periodEnd * 1000), + }, + }) + .catch((err) => console.log(err)); + } +} diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts index bae7f1d..a76571a 100644 --- a/lib/api/paymentCheckout.ts +++ b/lib/api/paymentCheckout.ts @@ -18,12 +18,18 @@ export default async function paymentCheckout( const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; + const session = await stripe.checkout.sessions.create({ customer: isExistingCustomer ? isExistingCustomer : undefined, line_items: [ { price: priceId, quantity: 1, + adjustable_quantity: { + enabled: true, + minimum: 1, + maximum: Number(process.env.STRIPE_MAX_QUANTITY || 100), + }, }, ], mode: "subscription", diff --git a/pages/api/v1/webhook/index.ts b/pages/api/v1/webhook/index.ts new file mode 100644 index 0000000..a266af3 --- /dev/null +++ b/pages/api/v1/webhook/index.ts @@ -0,0 +1,143 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import Stripe from "stripe"; +import handleSubscription from "@/lib/api/handleSubscription"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +const buffer = (req: NextApiRequest) => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + req.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + req.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + + req.on("error", reject); + }); +}; + +export default async function webhook( + req: NextApiRequest, + res: NextApiResponse +) { + if (process.env.NEXT_PUBLIC_DEMO === "true") + return res.status(400).json({ + response: + "This action is disabled because this is a read-only demo of Linkwarden.", + }); + + // see if stripe is already initialized + if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) { + return res.status(400).json({ + response: "This action is disabled because Stripe is not initialized.", + }); + } + + let event = req.body; + + const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + + const signature = req.headers["stripe-signature"] as any; + + try { + const body = await buffer(req); + event = stripe.webhooks.constructEvent(body, signature, endpointSecret); + } catch (err) { + console.error(err); + return res.status(400).send("Webhook signature verification failed."); + } + + // switch (event.type) { + // case "invoice.payment_succeeded": + // console.log( + // `Payment for ${event.data.object.customer_email} was successful!` + // ); + // await handleCreateSubscription(event.data.object); + // break; + // case "customer.subscription.updated": + // console.log( + // `Subscription for ${event.data.object.customer_email} was updated.` + // ); + // await handleUpdateSubscription(event.data.object); + // break; + // case "customer.subscription.deleted": + // console.log( + // `Subscription for ${event.data.object.customer_email} was deleted.` + // ); + // await handleDeleteSubscription(event.data.object); + // break; + // default: + // // Unexpected event type + // console.log(`Unhandled event type ${event.type}.`); + // } + + // Handle the event based on its type + const eventType = event.type; + const data = event.data.object; + + try { + switch (eventType) { + case "customer.subscription.created": + await handleSubscription({ + id: data.id, + active: data.status === "active" || data.status === "trialing", + quantity: data?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + case "customer.subscription.updated": + await handleSubscription({ + id: data.id, + active: data.status === "active", + quantity: data?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + case "customer.subscription.deleted": + await handleSubscription({ + id: data.id, + active: false, + quantity: data?.lines?.data[0]?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + case "customer.subscription.cancelled": + await handleSubscription({ + id: data.id, + active: !(data.current_period_end * 1000 < Date.now()), + quantity: data?.lines?.data[0]?.quantity ?? 1, + periodStart: data.current_period_start, + periodEnd: data.current_period_end, + }); + break; + + default: + console.log(`Unhandled event type ${eventType}`); + } + } catch (error) { + console.error("Error handling webhook event:", error); + return res.status(500).send("Server Error"); + } + + return res.status(200).json({ + response: "Done!", + }); +} diff --git a/prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql b/prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql new file mode 100644 index 0000000..051b61f --- /dev/null +++ b/prisma/migrations/20240924235035_add_quantity_to_subscriptions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 922480f..85a80e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -170,6 +170,7 @@ model Subscription { stripeSubscriptionId String @unique currentPeriodStart DateTime currentPeriodEnd DateTime + quantity Int @default(1) user User @relation(fields: [userId], references: [id]) userId Int @unique createdAt DateTime @default(now())