This week is all about taking payments with Stripe. We will implement a serverless function to charge a card and implement webhooks to update our Prisma user with courses they have purchased.
Extending User schema
In order to track which courses a user has purchased we will need to extend our User schema to contain a field for stripeId
.
// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
courses Course[]
stripeId String @unique
createdAt DateTime @default(now())
}
This will be used to map a Prisma user to a Stripe customer.
This modification will temporarily break our application as a
stripeId
is now a required field, and we are not setting one when we create a user in our application.
Let's create a migration to apply these changes to our DB.
npx prisma migrate dev --name add-stripe-id-to-user --preview-feature
Setting up Stripe
First thing you will need to do is create a Stripe account.
Once you have created an account and have landed on your Stripe dashboard, you will need to enter your business's details in order to activate your account. This will give you access to production API keys and allow you to process real payments. You do not need to activate your account to complete this series, but something you may want to do if you want to use this in the real world!
Next we need to install the two Stripe libraries in our application.
npm i stripe @stripe/stripe-js
stripe
is a backend library that we will use to process payments, and@stripe/stripe-js
is a frontend library that our client will use to initiate a payment session.
Now we need to modify our .env file to add our new API keys - these can be found in the Stripe dashboard under the "Get your API keys" panel. Make sure you use the "test" keys for local development.
// .env
// other secrets
STRIPE_SECRET_KEY=your-secret-key
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=your-publishable-key
We must prepend frontend environment variables with
NEXT_PUBLIC_
. Variables that do not contain this will only be available to our serverless fuctions.
Follow the same logic from Hosting on Vercel, automatic deploys with GitHub and configuring custom domains to add secrets in Vercel - without this our hosted application will not work.
Great! Now we should have stripe wired up!
Create Stripe customer
We will need to create a Stripe customer to keep a track of purchases and whether a subscription is active. We could do this when the user makes their first purchase, however, we do not know whether that will be when they purchase a particular course or activate their subscription. This would require us to add some logic to each of our payment scenarios to first check if a stripe user exists before charging their account. We can simplify this logic greatly by just creating a Stripe customer at the same time as our Prisma user - the first time a new user signs in to our application.
Let's modify our auth hook to create a stripe customer before we create a user in Prisma. That way we can use the newly created Stripe ID to create our user.
// pages/api/auth/hooks.js
// other imports
import initStripe from 'stripe'
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
module.exports = async (req, res) => {
// other auth code
const customer = await stripe.customers.create({
email,
})
const user = await prisma.user.create({
data: { email, stripeId: customer.id },
})
}
The whole file should look something like this.
// pages/api/auth/hooks.js
import { PrismaClient } from '@prisma/client'
import initStripe from 'stripe'
const prisma = new PrismaClient()
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
module.exports = async (req, res) => {
try {
const { email, secret } = JSON.parse(req.body)
if (secret === process.env.AUTH0_HOOK_SECRET) {
const customer = await stripe.customers.create({
email,
})
const user = await prisma.user.create({
data: { email, stripeId: customer.id },
})
console.log('created user')
} else {
console.log('You forgot to send me your secret!')
}
} catch (err) {
console.log(err)
} finally {
await prisma.$disconnect()
res.send({ received: true })
}
}
Great, now anytime a new user signs in we should be creating a Stripe customer, then a Prisma user that has a reference to the customer's ID.
Charging a card with Stripe
Now we want to build a serverless function that can process a payment for a particular course. We will need to tell this function which course the user is purchasing, so will use a Dynamic API Route to pass in the course ID. Let's create a new serverless function at /pages/api/charge-card/[courseId].js
.
// pages/api/charge-card/[courseId].js
module.exports = async (req, res) => {
const { courseId } = req.query
res.send(`charging card for course ${courseId}`)
}
You can trigger this serverless function by going to
http://localhost:3000/api/charge-card/any-value-you-want
. In this case it should print out "charging card for course any-value-you-want.
The next step would be working out how much we need to charge for the course. We could just pass this along with the request from the frontend, however, this could easily be tinkered with by the user.
We can't trust anything from the client!
Let's make a call to our Prisma DB to find out the real price.
// pages/api/charge-card/[courseId].js
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
module.exports = async (req, res) => {
const { courseId } = req.query
const course = prisma.course.findUnique({
where: {
id: parseInt(courseId),
},
})
await prisma.$disconnect()
res.send(`charging ${course.price} cents for ${courseId}`)
}
We use parseInt() here to turn the string we get from the req's query into an integer, which Prisma is expecting for the ID.
Next we want to know who the user is that is purchasing this course. This means we want the API route to only be accessible by logged in users. Let's wrap it in withApiAuthRequired
and work out who the user is by their session email.
// pages/api/charge-card/[courseId].js
import { PrismaClient } from '@prisma/client'
import { withApiAuthRequired, getSession } from '@auth0/nextjs-auth0';
const prisma = new PrismaClient()
module.exports = withApiAuthRequired(async (req, res) => {
const { courseId } = req.query
const { user: { email } } = getSession(req, res)
const course = prisma.course.findUnique({
where: {
id: parseInt(courseId),
},
})
const user = await prisma.user.findUnique({
where: {
email,
},
})
await prisma.$disconnect()
res.send(`charging ${user.email} ${course.price} cents for ${courseId}`)
})
Next we want to tell Stripe what we are actually charging the customer. We do this by creating a list of line items and a payment session.
// pages/api/charge-card/[courseId].js
//other imports
import initStripe from 'stripe'
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
module.exports = async (req, res) => {
// course and user stuff
const lineItems = [
{
price_data: {
currency: 'aud', // swap this out for your currency
product_data: {
name: course.title,
},
unit_amount: course.price,
},
quantity: 1,
},
]
const session = await stripe.checkout.sessions.create({
customer: user.stripeId,
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.CLIENT_URL}/success`,
cancel_url: `${process.env.CLIENT_URL}/cancelled`,
})
res.json({ id: session.id })
})
We need to provide a success and cancel url for stripe to forward the user to. These will need to be created at pages/success.js
and pages/cancelled.js
. Additionally, we need to create an environment variable for CLIENT_URL. Follow the previous steps to add this to the .env with the value http://localhost:3000
, and a new secret in Vercel with the value of whatever your hosted URL is - mine is https://courses-saas.vercel.app
.
Lastly we want to wrap all of this in a try/catch block in case something goes wrong. The whole file should look something like this.
// pages/api/charge-card/[courseId].js
import { withApiAuthRequired, getSession } from '@auth0/nextjs-auth0';
import { PrismaClient } from '@prisma/client'
import initStripe from 'stripe'
const prisma = new PrismaClient()
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
module.exports = withApiAuthRequired(async (req, res) => {
try {
const { courseId } = req.query
const { user: { email } } = getSession(req, res)
const course = prisma.course.findUnique({
where: {
id: parseInt(courseId),
},
})
const user = await prisma.user.findUnique({
where: {
email,
},
})
const lineItems = [
{
price_data: {
currency: 'aud', // swap this out for your currency
product_data: {
name: course.title,
},
unit_amount: course.price,
},
quantity: 1,
},
]
const session = await stripe.checkout.sessions.create({
customer: user.stripeId,
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.CLIENT_URL}/success`,
cancel_url: `${process.env.CLIENT_URL}/cancelled`,
})
res.json({ id: session.id })
} catch (err) {
res.send(err)
} finally {
await prisma.$disconnect()
}
})
Next we need to add a function in our frontend to trigger this payment. This block can be triggered from a button click anywhere in the app, and just needs to be passed a course ID to initiate the payment with Stripe.
import { loadStripe } from "@stripe/stripe-js";
import axios from 'axios'
const processPayment = async (courseId) => {
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
const { data } = await axios.get(`/api/charge-card/${courseId}`);
await stripe.redirectToCheckout({ sessionId: data.id });
}
Lastly, we want to know when a course has been purchased so that we can update our user in Prisma. This is made possible by Stripe's webhooks. Similarly to our Auth0 hook, we can subscribe to particular events, and when that happens Stripe will call our serverless function and tell us which user purchased a particular course.
We get a lot of data from Stripe about the transaction itself, but not which course or Prisma user. Let's modify our charge-card function to pass this across as metadata with the session.
// pages/api/charge-card/[courseId].js
const session = await stripe.checkout.sessions.create({
// other session stuff
payment_intent_data: {
metadata: {
userId: user.id,
courseId,
},
},
})
The whole file should look something like this.
// pages/api/charge-card/[courseId].js
import { withApiAuthRequired, getSession } from '@auth0/nextjs-auth0';
import { PrismaClient } from '@prisma/client'
import initStripe from 'stripe'
const prisma = new PrismaClient()
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
module.exports = withApiAuthRequired(async (req, res) => {
try {
const { courseId } = req.query
const { user: { email } } = getSession(req, res)
const course = prisma.course.findUnique({
where: {
id: parseInt(courseId),
},
})
const user = await prisma.user.findUnique({
where: {
email,
},
})
const lineItems = [
{
price_data: {
currency: 'aud', // swap this out for your currency
product_data: {
name: course.title,
},
unit_amount: course.price,
},
quantity: 1,
},
]
const session = await stripe.checkout.sessions.create({
customer: user.stripeId,
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.CLIENT_URL}/success`,
cancel_url: `${process.env.CLIENT_URL}/cancelled`,
payment_intent_data: {
metadata: {
userId: user.id,
courseId,
},
},
})
res.json({ id: session.id })
} catch (err) {
res.send(err)
} finally {
await prisma.$disconnect()
}
})
Now we can create an API route that can deal with these events from Stripe.
// pages/api/stripe-hooks
export default async (req, res) => {
// check what kind of event stripe has sent us
res.send({ received: true })
}
So that we don't get ourselves into the same problem we had with Auth0 Hooks, let's implement a signing secret to confirm that the request is coming from Stripe.
Let's first install the Stripe CLI to be able to simulate a webhook event. If you have macOS and homebrew installed, we can run this command.
brew install stripe/stripe-cli/stripe
Now run the following to authenticate the CLI with Stripe.
stripe login
Now we should be able to run the following to forward webhook events to our localhost.
stripe listen --forward-to localhost:3000/api/stripe-hooks
This will print out a signing secret to the terminal. Copy this into your .env file with the name STRIPE_SIGNING_SECRET
.
// .env
// other secrets
STRIPE_SIGNING_SECRET=your-webhook-signing-secret
Stripe provides a handy helper function called constructEvent
that can confirm whether this request was sent from them. Unfortunately, there is a little bit of tinkering we need to do to get this working in Next.js. Here is a really good guide that steps through the process.
Let's start by installing micro
.
npm i micro
Now we can update our stripe-hooks API route to validate the request is coming from Stripe.
// pages/api/stripe-hooks
import initStripe from 'stripe'
import { buffer } from 'micro'
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
export const config = { api: { bodyParser: false } }
export default async (req, res) => {
const reqBuffer = await buffer(req)
const signature = req.headers['stripe-signature']
const signingSecret = process.env.STRIPE_SIGNING_SECRET
let event
try {
event = stripe.webhooks.constructEvent(reqBuffer, signature, signingSecret)
} catch (err) {
console.log(err)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// check what kind of event stripe has sent us
res.send({ received: true })
}
The req
object from Vercel is not structured the way Stripe is expecting, so does not validate properly unless we do a bit of work.
stripe.webhooks.constructEvent() is a function that Stripe recommends using to confirm that they have sent this request. If it can validate this then it returns the Stripe event, otherwise it will throw an exception, and we will return a 400 status code. Read more here.
Okay, so now we can forget all about that validation and focus on processing the event we are receiving from Stripe.
// pages/api/stripe-hooks
export default async (req, res) => {
// signing logic
switch (event.type) {
case 'charge.succeeded':
// update user in prisma
console.log('charge succeeded')
break
default:
console.log(`Unhandled event type ${event.type}`)
}
}
event.type will contain a string for the event that has been triggered. We will extend this later for subscriptions so are using a case statement to keep it clear.
We can test that this is working by running the following command in a new terminal window - this requires the stripe listen
and npm run dev
commands to be running.
stripe trigger charge.succeeded
This should print out "charge succeeded" to the console.
Next we need to pull the user and course ID out of the metadata, and update the user's courses they have purchased in Prisma.
// pages/api/stripe-hooks
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default async (req, res) => {
// signing logic
const { metadata } = event.data.object
switch (event.type) {
case 'charge.succeeded':
// update user in prisma
if (metadata?.userId && metadata?.courseId) {
const user = await prisma.user.update({
where: {
id: parseInt(metadata.userId)
},
data: {
courses: {
connect: {
id: parseInt(metadata.courseId)
}
}
},
})
}
break
default:
console.log(`Unhandled event type ${event.type}`)
}
}
connect
is used to insert an existing course ID into the array of courses for the user. If we wanted to create this course then we would usecreate
.
The full file should look something like this.
// pages/api/stripe-hooks
import initStripe from 'stripe'
import { buffer } from 'micro'
import { PrismaClient } from '@prisma/client'
const stripe = initStripe(process.env.STRIPE_SECRET_KEY)
const prisma = new PrismaClient()
export const config = { api: { bodyParser: false } }
export default async (req, res) => {
const reqBuffer = await buffer(req)
const signature = req.headers['stripe-signature']
const signingSecret = process.env.STRIPE_SIGNING_SECRET
let event
try {
event = stripe.webhooks.constructEvent(reqBuffer, signature, signingSecret)
} catch (err) {
console.log(err)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
const { metadata } = event.data.object
switch (event.type) {
case 'charge.succeeded':
// update user in prisma
if (metadata?.userId && metadata?.courseId) {
const user = await prisma.user.update({
where: {
id: parseInt(metadata.userId)
},
data: {
courses: {
connect: {
id: parseInt(metadata.courseId)
}
}
},
})
}
break
default:
console.log(`Unhandled event type ${event.type}`)
}
res.send({ received: true })
}
Now we should have a complete solution where we can trigger a payment for a particular course in our app - we need to do it from the app, rather than the CLI so that it includes our metadata. This will make a request to our charge-card serverless function to create a payment session for that course. The user should then be taken to Stripe's UI where they can enter their credit card details, and then be redirected to our success page after they have been charged. In the background Stripe will call our webhook serverless function, which will update our Prisma user with the newly purchased course!
Amazing! And our app doesn't need to know anything about our users' credit card details!
The Stripe documentation is fantastic and I highly recommend checking out all the awesome things you can do beyond what we cover in this series!
Top comments (0)