diff --git a/package-lock.json b/package-lock.json index 0cac1ed..917b4c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "next": "^14.1.3", "next-auth": "^4.24.6", "nodemailer": "^6.9.13", + "pino": "^9.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-email": "2.1.1", @@ -3144,6 +3145,17 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3457,6 +3469,14 @@ "astring": "bin/astring" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.14", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", @@ -5361,6 +5381,14 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5421,6 +5449,14 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "engines": { + "node": ">=6" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -8004,6 +8040,14 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8264,6 +8308,79 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.0.0.tgz", + "integrity": "sha512-uI1ThkzTShNSwvsUM6b4ND8ANzWURk9zTELMztFkmnCQeR/4wkomJ+echHee5GMWGovoSfjwdeu80DsFIt7mbA==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.7.0", + "thread-stream": "^2.6.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -8600,6 +8717,19 @@ "node": ">=6" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8665,6 +8795,11 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9263,6 +9398,14 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -9550,6 +9693,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -9802,6 +9953,14 @@ "node": ">=10.0.0" } }, + "node_modules/sonic-boom": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", + "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sonner": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", @@ -9845,6 +10004,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stacktrace-parser": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", @@ -10326,6 +10493,14 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", + "integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/package.json b/package.json index 0127c33..098f4ed 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "next": "^14.1.3", "next-auth": "^4.24.6", "nodemailer": "^6.9.13", + "pino": "^9.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-email": "2.1.1", diff --git a/prisma/migrations/20240426110344_add_new_status/migration.sql b/prisma/migrations/20240426110344_add_new_status/migration.sql new file mode 100644 index 0000000..0efba88 --- /dev/null +++ b/prisma/migrations/20240426110344_add_new_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "MembershipStatus" ADD VALUE 'WAITING'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43cd773..c5c7741 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,19 +61,19 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - role UserRole @default(member) - email String? @unique - emailVerified DateTime? - image String? - stripeCustomerId String? - accounts Account[] - sessions Session[] - memberships Membership[] + id String @id @default(cuid()) + name String? + role UserRole @default(member) + email String? @unique + emailVerified DateTime? + image String? + stripeCustomerId String? + accounts Account[] + sessions Session[] + memberships Membership[] } -enum UserRole{ +enum UserRole { member admin } @@ -88,6 +88,7 @@ model VerificationToken { enum MembershipStatus { PENDING + WAITING ACTIVE REJECTED UNSUBSCRIBED diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..a202719 --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,119 @@ +import Stripe from "stripe" +import { stripe } from "@/services/stripe" +import { env } from "@/env" +import pino from "pino" +import { getLogger } from "@/logging/log-util" +import { db } from "@/services/db" +import { MembershipStatus } from "@prisma/client" + +const logger = getLogger("stripe-webhook") + +export async function POST(req: Request) { + const body = await req.text() + const signature = req.headers.get("Stripe-Signature") as string + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + env.STRIPE_WEBHOOK_SECRET, + ) + } catch (error: any) { + return new Response(`Webhook Error: ${error.message}`, { status: 400 }) + } + + const session = event.data.object as Stripe.Subscription + + if (event.type === "customer.subscription.updated") { + return await updateSubscription(session) + } + + if (event.type === "customer.subscription.deleted") { + return await cancelSubscription(session) + } + + return new Response(`Nothing to do: ${event.type}`, { status: 200 }) +} + +async function cancelSubscription( + subscription: Stripe.Subscription, +): Promise { + logger.info(`Subscription canceled: ${subscription.id}`) + let membership = await db.membership.findUnique({ + where: { + stripeSubscriptionId: subscription.id, + }, + }) + if (!membership) { + logger.warn(`Membership not found for subscription: ${subscription.id}`) + return new Response(`No membership found`, { status: 404 }) + } + + await db.membership.update({ + where: { + id: membership.id, + }, + data: { + status: "EXPIRED", + }, + }) + + return new Response(`Subscription updated`, { status: 200 }) +} + +async function updateSubscription( + subscription: Stripe.Subscription, +): Promise { + logger.info(`Subscription updated: ${subscription.id}`) + + if (subscription.status !== "active") { + logger.info(`Subscription is not active: ${subscription.id}`) + } + + let membership = await db.membership.findUnique({ + where: { + stripeSubscriptionId: subscription.id, + }, + }) + if (!membership) { + logger.warn(`Membership not found for subscription: ${subscription.id}`) + return new Response(`No membership found`, { status: 404 }) + } + + try { + await db.membership.update({ + where: { + id: membership.id, + }, + data: { + status: getNextStatusGivenCurrentStatus(membership.status), + lastPaymentAt: new Date(subscription.current_period_start), + expiresAt: new Date(subscription.current_period_end), + }, + }) + } catch (error: any) { + logger.error(`Error updating subscription: ${error.message}`) + return new Response(`Error updating subscription`, { status: 500 }) + } + + return new Response(`Subscription updated`, { status: 200 }) +} + +function getNextStatusGivenCurrentStatus( + currentStatus: MembershipStatus, +): MembershipStatus { + if ( + currentStatus === MembershipStatus.PENDING || + currentStatus === MembershipStatus.WAITING + ) { + return MembershipStatus.WAITING + } + + if (currentStatus === MembershipStatus.ACTIVE) { + return MembershipStatus.ACTIVE + } + + throw new Error(`Unexpected status: ${currentStatus}`) +} diff --git a/src/env.js b/src/env.js index 8e2afad..7f544ea 100644 --- a/src/env.js +++ b/src/env.js @@ -38,6 +38,8 @@ export const env = createEnv({ NEXTAUTH_URL: process.env.NEXTAUTH_URL, NODE_ENV: process.env.NODE_ENV, STRIPE_PRIVATE_KEY: process.env.STRIPE_PRIVATE_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + LOG_LEVEL: process.env.LOG_LEVEL, }, /** @@ -78,7 +80,9 @@ export const env = createEnv({ NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), STRIPE_PRIVATE_KEY: z.string(), + STRIPE_WEBHOOK_SECRET: z.string(), }, /** diff --git a/src/logging/log-util.ts b/src/logging/log-util.ts new file mode 100644 index 0000000..df5ee30 --- /dev/null +++ b/src/logging/log-util.ts @@ -0,0 +1,6 @@ +import pino, { Logger } from "pino"; +import { env } from "@/env"; + +export function getLogger(name: string): Logger { + return pino({ name, level: env.LOG_LEVEL }); +}