At the time of writting, redwood requires node version < 17. So if you don't have it already, install the latest node v16 version on your machine (I recommend using nvm to manage your node versions)
nvm install v16.15.1
nvm alias default v16.15.1
nvm use v16.15.1
npm install -g yarn
brew install postgres
on mac os in which case this page can be useful, otherwise you can look up postgres download page
Another option is to use docker (that's what I am using), here is the docker compose file you can use:
version: '3.1'
services:
db:
image: postgres:12
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=example
- POSTGRES_DB=redwoodstripe
ports:
- 5432:5432
volumes:
- ./db-data/redwoodstripe:/var/lib/postgresql/data
And then docker-compose up -d
Create the app
yarn create redwood-app --ts ./redwood-stripe
Add tailwind ui framework
yarn rw setup ui tailwindcss
That's it...
Create backend authentication
yarn rw setup auth dbAuth
Create front-end authentication
yarn rw generate dbAuth
Update you prisma schema to include a user model that support the authentication we just installed. Remove the UserExample
model from prisma.schema
and add:
datasource db {
provider = "postgres"
url = env("DATABASE_URL")
}
enum SubscriptionStatus {
init
success
failed
}
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String
salt String
roles String[]
stripeClientSecret String?
resetToken String?
resetTokenExpiresAt DateTime?
subscriptionName String?
subscriptionStatus SubscriptionStatus?
}
A few fields we're adding here:
- stripeClientSecret: When we will initiate a payment intent this value is going to be needed in the front end to authenticate the transaction. But we also save it in the backend until stripe calls the stripe webhook endpoint with a
payment_intent.succeeded
event (or apayment_intent.payment_failed
) in which case we can update thesubscriptionStatus
accordingly and delete thestripeClientSecret
because it should not be reused - subscriptionName: Keeps track of which kind of subscription the user has
- roles: We're not adding this one, but we're using it. It will contain the value
seller
to mark users that have a subscription and can sell stuff on our platform - subscriptionStatus: Keeps track of whether the user paid his subscription or not
Generate a session secret
yarn rw generate secret
Setup db connection in .env:
DATABASE_URL=postgresql://postgres:example@localhost/redwoodstripe
SESSION_SECRET=<ouput from yarn rw generate secret>
NODE_ENV=development
Creare migration and user db table and call it whatever you want but initial migration
makes up for a good name for this ... initial migration
yarn rw prisma migrate dev
Fire up the dev server
yarn rw dev
You should be able to signup on the /signup page
Or almost... You will get a routes.home is not a function
error page.
Well, let's create a homepage first
yarn rw generate page home
In Routes.tsx
a line was added:
<Route path="/home" page={HomePage} name="home" />
Let's make it the home page by updating the path
<Route path="/" page={HomePage} name="home" />
Typically a market place has 2 different kind of users. Regular users (buyers / customers) and sellers. We could also have admins, but that's outside the scope of this tutorial. We will use the roles attribute on the user model to store this information.
Update SignupPage.tsx
with the following, right after <FieldError name="password" className="rw-field-error" />
:
<label name="seller" className="rw-label"> Seller </label>
<CheckboxField name="seller" className="rw-input" />
And while we're in this file, let's make another update because we want to signup with emails, not username, so modify the username input to be:
<Label
name="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Email
</Label>
<EmailField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: { value: true, message: 'Email is required' },
pattern: {
value: /(.+)@(.+){2,}\.(.+){2,}/,
message: 'Incorrect email format',
},
}}
/>
Finally in api/src/functions/auth.ts
, look for signupOptions' handler and add userAttributes to capture the seller boolean from the form's checkbox that we just added:
handler: ({ username, hashedPassword, salt, userAttributes }) => {
return db.user.create({
data: {
email: username,
hashedPassword,
salt,
roles: userAttributes.seller ? ['seller'] : [],
},
})
},
We're now starting our first real task, so first step back and look up the internet if someone, maybe, has already done that and has a neat github repo to copy paste. The internet is a magical place and such repo indeed exists, and David Price himself contributed to it. I invite you to try to this redwood/stripe example here and there is even a live demo here
Signup for a new Stripe account here. Note that you need to verify your email, but you don't need to complete the account activation to play with sandbox environment (called test mode
and is the default when you create an account)
yarn workspace api add stripe
The documentation for this API package can be found here
According to this documentation, we will now need to create an instance of the API. Where can we add that? Well, prisma is already doing the same thing and is creating this singleton inside lib/db.ts
, so I think lib/stripe.ts
is a decent idea. There you can add this code:
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SK, {
apiVersion: '2020-08-27',
})
You now need to add a few variables to your .env file:
STRIPE_PK=pk_test_...
STRIPE_SK=sk_test_...
STRIPE_WEBHOOK_SK=whsec_...
The first 2 are coming directly from your stripe dashboard The last one requires you to install the stripe deamon on your machine, you can find it here Once installed, run the command
stripe listen --api-key=sk_test_... --print-secret
We now want our marketplace to have the choice between 2 subscriptions:
- The Basic subscription. It won't cost much every month but then we'll take so much commission off the sales that this will probably not be sustainable so your sellers will want to upgrade to..
- The Pro subscription. A tad more pricey but then we just take 3% commission on each sale! A steal
As we've already seen, Redwood has a great CLI tool. Among the available command is exec
that allows you to run a script that you put inside the scripts
folder. So, although we could go on the Stripe Dashboard and create products manually, what's the fun of that? So let's go ahead and add a seed-stripe-subscriptions.ts
inside the scripts folder
We'll start using the stripe API. We'll use the 2 following commands:
We first define our subscriptions as a list of Stripe.ProductCreateParams
import Stripe from 'stripe'
import { stripe } from 'api/src/lib/stripe'
const subscriptions: Stripe.ProductCreateParams[] = [
{
name: 'Basic',
description: "We'll take a 40% commission on everything you sell, hahaha",
default_price_data: {
currency: 'usd',
unit_amount: 3500,
recurring: {
interval: 'month',
},
},
},
{
name: 'Pro',
description:
"We'll take a modest 3% commission on everything you sell, your success is our success",
default_price_data: {
currency: 'usd',
unit_amount: 155000,
recurring: {
interval: 'month',
},
},
},
]
We can then retrieve the products and check that they don't already exist on your Stripe account inside the default function of the script we just created:
export default async () => {
console.log('Getting products')
const { data: products } = await stripe.products.list({
active: true,
})
if (products.length) {
const productNames = products.map((p) => p.name)
for (const subscription of subscriptions) {
if (productNames.includes(subscription.name)) {
console.log(
`The subscription ${subscription.name} exists already, delete it from your Stripe dashboard to run this script`
)
process.exit(1)
}
}
}
}
And finally create the subcriptions
console.log('Seeding subscriptions')
for (const subscription of subscriptions) {
console.log(`Creating ${subscription.name}`)
await stripe.products.create(subscription)
}
And let the magic happen 🪄 yarn rw exec seed-stripe-subscriptions --no-prisma
You should get:
[XX:XX:XX] Running script [started]
Getting products
Seeding subscriptions
Creating Basic
Creating Pro
Done
[XX:XX:XX] Running script [completed]
We need a new graphQL query to retrieve all available subscriptions from our Stripe account
To do this create api/src/graphql/subscriptions.sdl.ts
and define your query like this:
export const schema = gql`
scalar URL
type Subscription {
id: ID!
name: String!
description: String
price: Int!
currency: String!
}
type Query {
subscriptions: [Subscription!]! @skipAuth
}
`
Your IDE should see that the subscription query implementation is missing (at least VS Code does for me...), you can let your IDE create the skeleton implementation for this query for you, or create a file api/src/services/subscriptions/subscriptions.ts
and add the content that retrieve the list of subscription, this is very similar to our seeder earlier
import Stripe from 'stripe'
import { stripe } from 'src/lib/stripe'
export const subscriptions = async () => {
// Get a list of active products
const { data: products } = await stripe.products.list({
active: true,
})
// Return the list of objects as defined in the sdl
return products.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
price: (p.default_price as Stripe.Price).unit_amount,
currency: (p.default_price as Stripe.Price).currency,
}))
}
Let's now try our new graphql endpoint! I usually use Postman to do that, but it's easier to print cURL commands, so here is our new graphql query:
curl --location --request POST 'http://localhost:8910/.redwood/functions/graphql' \
--header 'Content-Type: application/json' \
--data-raw '{"query":"query { subscriptions {id name price currency}}","variables":{}}'
And... it fails!
When we look at the server output we see Cannot return null for non-nullable field Subscription.price.
Hmm, seems like that default_price
is not what it supposed to be. And indeed, if we add a console.log(products)
statement before our return
statement and run that cURL command again, the server output is going to give a bit more insights into what's going on:
api | [
api | {
api | id: 'prod_LprBCp8SgI4rtX',
api | object: 'product',
api | active: true,
api | attributes: [],
api | created: 1654643163,
api | default_price: 'price_1L8BTLCoThLt2WWY4gs2sBdk',
api | description: "We'll take a 3% commission on everything you sell",
api | images: [],
api | livemode: false,
api | metadata: {},
api | name: 'Pro',
api | package_dimensions: null,
api | shippable: null,
api | statement_descriptor: null,
api | tax_code: null,
api | type: 'service',
api | unit_label: null,
api | updated: 1654643164,
api | url: null
api | },
api | {
api | id: 'prod_LprBPR78YnJ34t',
api | object: 'product',
api | active: true,
api | attributes: [],
api | created: 1654643163,
api | default_price: 'price_1L8BTLCoThLt2WWYTQOSOZJ0',
api | description: "We'll take a 10% commission on everything you sell",
api | images: [],
api | livemode: false,
api | metadata: {},
api | name: 'Basic',
api | package_dimensions: null,
api | shippable: null,
api | statement_descriptor: null,
api | tax_code: null,
api | type: 'service',
api | unit_label: null,
api | updated: 1654643163,
api | url: null
api | }
api | ]
The default_price returns a price id. Not super useful. But the stripe API sort of work like graphql and all you need to do is to add an expand
param to the API call and it can populate that default_price for you
So let's change the list API call to
const { data: products } = await stripe.products.list({
active: true,
expand: ['data.default_price'],
})
It seems odd to me to have to put data.
but if you don't put it, it gives you a nice error message that tells you to put it.
We finally get the result that we want:
{
"data": {
"subscriptions": [
{
"id": "prod_LprBCp8SgI4rtX",
"name": "Pro",
"price": 15500,
"currency": "usd",
"priceId": "price_1L8BTLCoThLt2WWY4gs2sBdk"
},
{
"id": "prod_LprBPR78YnJ34t",
"name": "Basic",
"price": 3500,
"currency": "usd",
"priceId": "price_1L8BTLCoThLt2WWYTQOSOZJ0"
}
]
}
}
Let's start by creating the page. But this page will first need to load the subscriptions to let the user choose among them. Redwood has a special type of component for that called a Cell - A cell is a special module with a defined set of exported variables and function that get pass to Redwood's internal createCell
function that spits out an actual React component.
yarn rw generate cell Subscriptions
We'll also need a page to host this cell
yarn rw generate page PickSubscription
,
and in PickSubscription.ts
call the cell
import SubscriptionsCell from 'src/components/SubscriptionsCell'
const PickSubscriptionPage = () => {
return <SubscriptionsCell />
}
export default PickSubscriptionPage
Note the magic, we call src/components/SubscriptionsCell
instead of src/components/SubscriptionsCell/SubscriptionsCell
as we do for regular components
We need to update the Cell in order to get what we want and initiate the checkout process
1- The query does not return enough information, we also need name, price, currency, description
export const QUERY = gql`
query SubscriptionsQuery {
subscriptions {
id
name
price
currency
description
}
}
`
2- Although we don't want to do any styling, displaying object with JSON.stringify is still a little below us. So let's format the subscriptions in the success handler
export const Success = ({
subscriptions,
}: CellSuccessProps<{ subscriptions: Subscription[] }>) => {
const { currentUser } = useAuth()
return (
<div className="w-80 mx-auto">
<p className="text-slate-500 text-center">Pick a subscription</p>
<ul>
{subscriptions.map((item) => {
return (
<li key={item.id}>
<button
onClick={() => {
/* Get client secret from stripe... */
}}
disabled={currentUser?.subscriptionName === item.name}
className={`py-2 px-4 ${
currentUser?.subscriptionName === item.name
? 'bg-slate-200'
: 'bg-indigo-400'
} rounded-md text-white font-bold w-80 mt-8`}
>
{item.name} - {item.description} - <b>${item.price / 100}/mo</b>
</button>
</li>
)
})}
</ul>
</div>
)
}
We want to compel sellers to pay for a subscription. For this we will add a layout where we will put a menu with a special option for seller, login/signup buttons for signed out user and a redirection to the pick subscription page for seller that have not paid for a subscription yet.
yarn rw generate layout MainLayout
And add this code in the body of the MainLayout
component:
import { useAuth } from '@redwoodjs/auth'
import { Link, navigate, routes, useLocation } from '@redwoodjs/router'
import { useEffect } from 'react'
type MainLayoutProps = {
children?: React.ReactNode
}
const MainLayout = ({ children }: MainLayoutProps) => {
const location = useLocation()
const { isAuthenticated, currentUser, logOut } = useAuth()
const isAuthorizedSeller =
currentUser?.subscriptionStatus === 'success' &&
currentUser?.roles.includes('seller')
useEffect(() => {
if (
location.pathname !== routes.pickSubscription() &&
currentUser?.subscriptionStatus !== 'success' &&
currentUser?.roles.includes('seller')
) {
navigate(routes.pickSubscription())
}
}, [currentUser, location])
return (
<div>
<div className="overflow-hidden p-2 bg-slate-100 flex justify-between text-slate-500">
<div className="font-bold italic">Upmarket</div>
<nav>
<ul className="flex gap-3 text-sm">
{isAuthenticated && (
<li className="text-slate-300 italic text-sm">
{currentUser.email}
</li>
)}
<li>
<Link to={routes.home()}>Home</Link>
</li>
{isAuthenticated ? (
<li>
<button onClick={logOut}>Logout</button>
</li>
) : (
<>
<li>
<Link to={routes.login()}>Login</Link>
</li>
<li>
<Link to={routes.signup()}>Signup</Link>
</li>
</>
)}
{isAuthorizedSeller && (
<li>
<Link to={routes.sellStuff()}>Sell stuff</Link>
</li>
)}
</ul>
</nav>
</div>
<div className="m-3">{children}</div>
</div>
)
}
export default MainLayout
Redwood's useAuth
hook really comes in handy here and gives us everything we need to display information based on the currentUser
and even gives us a simple function to call to log the user out.
We also want to create a new page where the seller will be able to manage his products
yarn rw generate page SellStuff
Finally let's reorganize a bit our Routes.tsx
to protect some routes and include our new layout:
import { Router, Route, Set, Private } from '@redwoodjs/router'
import MainLayout from './layouts/MainLayout/MainLayout'
const Routes = () => {
return (
<Router>
<Set wrap={MainLayout}>
<Private unauthenticated="home">
<Route
path="/pick-subscription"
page={PickSubscriptionPage}
name="pickSubscription"
/>
<Route path="/sell-stuff" page={SellStuffPage} name="sellStuff" />
</Private>
<Route path="/" page={HomePage} name="home" />
</Set>
<Route path="/login" page={LoginPage} name="login" />
<Route path="/signup" page={SignupPage} name="signup" />
<Route
path="/forgot-password"
page={ForgotPasswordPage}
name="forgotPassword"
/>
<Route
path="/reset-password"
page={ResetPasswordPage}
name="resetPassword"
/>
<Route notfound page={NotFoundPage} />
</Router>
)
}
export default Routes
We intentionally left the onClick
handler empty, because that's where we'll want to initiate a recurring payment. Stripe is providing a great repository full examples of different subscription models.
The Node implementation of these examples all use Express. With Redwood we'll use graphql instead. Let's start by adding the mutation to subscriptions.sdl.ts
:
type Mutation {
createSubscription(id: String!): String! @requireAuth
}
And then add the implementation in part1/api/src/services/subscriptions/subscriptions.ts
export const createSubscription = async ({ id }: { id: string }) => {
const userId = context.currentUser?.id
if (userId && id) {
const user = await getUser(+userId)
const product = await getSubscription(id)
const customer = await stripe.customers.create({
name: user.email,
})
const priceId = product.default_price as string
const { clientSecret } = await createStripeSubscription(
customer.id,
priceId
)
await db.user.update({
where: { id: user.id },
data: {
stripeClientSecret: clientSecret,
subscriptionStatus: 'init',
subscriptionName: product.name,
},
})
return clientSecret
}
throw new Error('Could not create subscription')
}
async function getUser(userId: number): Promise<User> {
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
throw new Error(`No users found with id=${userId}`)
}
return user
}
async function getSubscription(
subscriptionId: string
): Promise<Stripe.Product> {
const product = await stripe.products.retrieve(subscriptionId)
if (!product || !product.active || !product.default_price) {
throw new Error(`No subscriptions found with id=${subscriptionId}`)
}
return product
}
async function createStripeSubscription(
customerId: string,
priceId: string
): Promise<{ clientSecret: string }> {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [
{
price: priceId,
},
],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
})
return {
clientSecret: (
(subscription.latest_invoice as Stripe.Invoice)
.payment_intent as Stripe.PaymentIntent
).client_secret,
}
}
Let's unpack this a little bit:
- We retrieve the
userId
from the context and subscription'sid
from the mutation's arguments - Based on those we retrieve a user and a subscription (set
getUser
andgetSubscription
) - One subtlety about
getSubscription
is that it needs to return theclient_secret
, available in the deeply nested objectlatest_invoice.payment_intent
. Fortunately Stripe API allows you to retrieve deeply nested objects with theexpand
keyword, all we have to do is
expand: ['latest_invoice.payment_intent'],
- We can now update the user model with the
subscriptionStatus
,subscriptionName
andclientSecret
. We need the client secret because when Stripe will confirm the trasaction, it will send this clientSecret and we can connect it to the user this way.
We're almost ready to try our subscription registration. We need 3 last things...
1. Call our new serverless function
First things first, let's update SubscriptionCell.tsx
, add the createSubscription
mutation and update the onClick
handler:
const CREATE_SUBSCRIPTION = gql`
mutation CreateSubscriptionMutation($id: String!) {
createSubscription(id: $id)
}
`
export const Success = ({
subscriptions,
}: CellSuccessProps<{ subscriptions: Subscription[] }>) => {
const { currentUser, reauthenticate } = useAuth()
const [clientSecret, setClientSecret] = useState('')
const [create, { data }] = useMutation(CREATE_SUBSCRIPTION)
useEffect(() => {
if (data) {
if (data.createSubscription) {
reauthenticate()
setClientSecret(data.createSubscription)
} else {
toast.error('Could not create subscription')
}
}
}, [data])
return (
<div className="w-80 mx-auto">
<p className="text-slate-500 text-center">Pick a subscription</p>
<ul>
{subscriptions.map((item) => {
return (
<li key={item.id}>
<button
onClick={() =>
create({
variables: { id: item.id },
})
}
disabled={currentUser?.subscriptionName === item.name}
className={`py-2 px-4 ${
currentUser?.subscriptionName === item.name
? 'bg-slate-200'
: 'bg-indigo-400'
} rounded-md text-white font-bold w-80 mt-8`}
>
{item.name} - {item.description} - <b>${item.price / 100}/mo</b>
</button>
</li>
)
})}
</ul>
{clientSecret && (
<Subscribe
clientSecret={clientSecret}
onClose={() => {
setClientSecret('')
}}
/>
)}
</div>
)
}
Note the await reauthenticate()
call. This is to make sure our local currentUser
is up to date with the latest information concerning the subscription. If the user had a subscription before, the backend reset the subscription state to init
.
2. Display a form to enter payment information
Note that we call a Subscribe
component but we haven't created it yet. So let's add it
yarn rw generate component Subscribe
That Subscribe
component is a simple form whose content will be that CC fields handled directly by Stripe's CardElement
component, all we have to do is to handle the submit event and send the form back to Stripe:
import { useAuth } from '@redwoodjs/auth'
import { navigate, routes } from '@redwoodjs/router'
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { useEffect, useState } from 'react'
const Subscribe = ({
clientSecret,
onClose,
}: {
clientSecret: string
onClose: () => void
}) => {
const { currentUser, reauthenticate } = useAuth()
const [paymentDone, setPaymentDone] = useState(false)
const [message, setMessage] = useState('')
const stripe = useStripe()
const elements = useElements()
useEffect(() => {
if (!paymentDone) return
if (currentUser.subscriptionStatus === 'success') {
navigate(routes.home())
} else {
setTimeout(() => reauthenticate(), 1000)
}
}, [currentUser, reauthenticate, paymentDone])
if (!stripe || !elements || !currentUser) {
return null
}
const handleSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault()
setMessage('Submitting payment...')
const cardElement = elements.getElement(CardElement)
const { error, paymentIntent } = await stripe.confirmCardPayment(
clientSecret,
{
payment_method: {
card: cardElement,
billing_details: {
name: currentUser.email,
},
},
}
)
if (error) {
setMessage(error.message)
return
}
if (paymentIntent.status === 'succeeded') {
await reauthenticate()
navigate(routes.sellStuff())
}
}
return (
<div className="fixed left-1/2 top-20 -ml-48 p-5 w-96 shadow-lg rounded-md bg-slate-200 text-slate-500">
<div className="font-bold text-sm uppercase tracking-wide mb-4 pb-2 text-center border-b border-slate-300">
Subscribe
</div>
<form onSubmit={handleSubmit}>
<CardElement />
{message && <div className="text-slate-400 my-2 italic">{message}</div>}
<div className="overflow-hidden">
<button
className="mt-4 float-left py-2 px-4 text-indigo-400 rounded-md font-bold"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="mt-4 float-right py-2 px-4 bg-indigo-400 rounded-md text-white font-bold"
>
Subscribe now
</button>
</div>
</form>
</div>
)
}
export default Subscribe
This also redirects the user our sellStuff
if payment is successful
3. Handle subscription status change in the backend
And try!
Go to http://localhost:8910/pick-subscription and pick a subscription, fill out the stripe checkout form (use 4242 4242 4242 4242 as CC number, rest doesn't matter as long as it is valid) and you should get redirected to your ngrok success url. But it fails... With something like 'Invalid Host Header".... Bummer. Interneting, interneting. It turns out that our dev server doesn't accept random host. The good news is here you can start your dev server with any webpack option and as it turns out, there is an option to allow any host. So, you can stop your dev server and restart it with this modified command: yarn rw dev --forward="--allowed-hosts=all"
make that test again and you should get to the page with the path /subscription-callback?success=true
as you defined it earlier in the serverless function.
Let's create a serverless function that will handle events coming from stripe directly
yarn rw generate function stripeWebhook
For the moment let's just log the event, so replace the content of api/src/functions/stripeWebhook/stripeWebhook.ts with:
import type { APIGatewayEvent } from 'aws-lambda'
import { logger } from 'src/lib/logger'
import { SubscriptionStatus } from '@prisma/client'
import { db } from 'src/lib/db'
export const handler = async (event: APIGatewayEvent) => {
logger.info('Invoked stripeWebhook function')
console.log(event.body)
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: { received: true },
}),
}
}
According to the Stripe docs, you need to install Stripe cli in order to capture webhook on your local machine. On a Mac you can do brew install stripe/stripe-cli/stripe
, for other platforms, Stripe got your back
then stripe login
in your terminal
then stripe listen --forward-to http://localhost:8910/.redwood/functions/stripeWebhook
We can now test the event
stripe trigger payment_intent.succeeded
This should output an event object in JSON format on your terminal window where you started Redwood
I was curious to see all the webhook that are triggered during the checkout process, but since those events are pretty verbose, I output just the type
console.log(JSON.parse(event.body).type)
And then go back to http://localhost:8910/pick-subscription and go through the process again, here is the list of event that stripe is sending to my webhook
charge.succeeded
checkout.session.completed
payment_method.attached
customer.created
customer.updated
invoice.created
invoice.finalized
customer.subscription.created
invoice.updated
customer.subscription.updated
invoice.paid
invoice.payment_succeeded
payment_intent.succeeded
payment_intent.created
According to the docs, it seems that payment_intent.succeeded
is the event that signals successful payment
I then tried tried with one of the test CC with unsufficient funds 4000000000009995
, stripe has a list of them
And the list of events becomes
customer.created
charge.failed
customer.updated
invoice.created
invoice.finalized
customer.subscription.created
customer.updated
invoice.payment_failed
invoice.updated
payment_intent.created
payment_intent.payment_failed
We will take payment_intent.payment_failed
as an indicator of failed payment
We can now update our stripeWebhook function accordingly
import type { APIGatewayEvent } from 'aws-lambda'
import { logger } from 'src/lib/logger'
import { SubscriptionStatus } from '@prisma/client'
import { db } from 'src/lib/db'
export const handler = async (event: APIGatewayEvent) => {
logger.info('Invoked stripeWebhook function')
const stripeEvent = JSON.parse(event.body)
const subscriptionStatus: SubscriptionStatus | null =
stripeEvent.type === 'payment_intent.succeeded'
? 'success'
: stripeEvent.type === 'payment_intent.payment_failed'
? 'failed'
: null
if (subscriptionStatus) {
const paymentIntent = stripeEvent.data.object
await db.user.updateMany({
where: { stripeClientSecret: paymentIntent.client_secret },
data: {
stripeClientSecret: null,
subscriptionStatus,
},
})
}
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: { received: true },
}),
}
}
There is actually a problem with our subscribe component, when we do that:
if (paymentIntent.status === 'succeeded') {
await reauthenticate()
navigate(routes.sellStuff())
}
We reauthenticate and expect the user in the database to have the right subscriptionStatus
.
But we don't know when stripe is calling our webhook to update the subscriptionStatus
in our database. In this case we could use Apollo subscriptions, but for the sake of simplicity, we will just poll our backend and check if our user has been updated. Stripe won't send the notification to our webhook long after the payment has been confirmed. What we need is to just say that our payment is done, and then reauthenticate every second until the currentUser.subscriptionStatus
is updated.
const [paymentDone, setPaymentDone] = useState(false)
useEffect(() => {
if (!paymentDone) return
if (currentUser.subscriptionStatus === 'success') {
navigate(routes.home())
} else {
setTimeout(() => reauthenticate(), 1000)
}
}, [currentUser, reauthenticate, paymentDone])
and at the end of the handleSubmit
method:
if (paymentIntent.status === 'succeeded') {
setMessage('Waiting for confirmation...')
setPaymentDone(true)
reauthenticate()
}
Well done you made it through part 1! It only gets easier from here (well I don't know I have not written part 2 yet, but I hope) A few things you can do now: