Last active
March 16, 2024 10:08
-
-
Save rohailtaha/19d267aea7f28244e39d6f56c5151d6f to your computer and use it in GitHub Desktop.
Stripe subscriptions in Nextjs/Nodejs with React
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
import { getPricingPlanId } from '@/api-utils/utils'; | |
import { NextResponse } from 'next/server'; | |
import Stripe from 'stripe'; | |
export async function POST(request: Request) { | |
const body = await request.json(); | |
const stripe = new Stripe(process.env.STRIPE_SECRET!); | |
const domain = | |
process.env.NODE_ENV === 'development' | |
? 'localhost:3000' | |
: process.env.DOMAIN; | |
const session = await stripe.checkout.sessions.create({ | |
payment_method_types: ['card'], | |
line_items: [ | |
{ | |
price: getPricingPlanId(body.planName), | |
quantity: 1, | |
}, | |
], | |
mode: 'subscription', | |
success_url: `http://${domain}/billing?checkout=subscription_started`, | |
cancel_url: `https://${domain}/billing?checkout=subscription_failed`, | |
}); | |
return NextResponse.json({ data: session.id }); | |
} |
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
// run this funciton when update subscription is confirmed by user | |
async function handleUpdate() { | |
try { | |
const response = await fetch('/api/subscription', { | |
method: 'PUT', | |
body: JSON.stringify({ subscriptionName: updateTo }), | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
}); | |
} catch (err) { | |
console.error(err); | |
} | |
} |
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
// run this funciton when delete subscription is confirmed by user | |
async function handleDelete() { | |
try { | |
setLoading(true); | |
const response = await fetch('/api/subscription', { | |
method: 'DELETE', | |
}); | |
} catch (err) { | |
console.error(err); | |
} | |
} |
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
export async function PUT(request: Request) { | |
try { | |
const body = await request.json(); | |
const token = (await getToken({ req: request as NextRequest }))!; | |
const subscription = (await prismaClient.subscriptions.findFirst({ | |
where: { userId: token.id as number, stripeStatus: 'active' }, | |
}))!; | |
if (!subscription) { | |
return jsonErrorResponse( | |
ERROR_TYPE.NoActiveSubscription, | |
'User does not have any active subscription', | |
422 | |
); | |
} | |
// If there is a subscription that is pending cancelation, then don't update. | |
if (subscription.endsAt !== null) { | |
return jsonErrorResponse( | |
ERROR_TYPE.DisallowedSubscriptionUpdate, | |
'You already have a subscription that is pending cancelation. You can only update your subscription after the current subscription is cancelled at the end of the billing cycle. Or you can resume your subscription and then update.', | |
422 | |
); | |
} | |
const stripe = getStripe(); | |
const stripeSubscription = await stripe.subscriptions.retrieve( | |
subscription.stripeId | |
); | |
const currentSubscriptionItemId = stripeSubscription.items.data[0].id; | |
const updatedSubscription = await stripe.subscriptions.update( | |
subscription.stripeId, | |
{ | |
// This proration behaviour will bill the user immediately instead of accumulating the amount | |
// (for updated subscription) in the next billing cycle. | |
proration_behavior: 'always_invoice', | |
items: [ | |
{ | |
id: currentSubscriptionItemId, | |
deleted: true, | |
}, | |
{ | |
price: getPriceId(body.subscriptionName), | |
quantity: 1, | |
}, | |
], | |
} | |
); | |
const subscriptionItems = updatedSubscription.items.data; | |
await prismaClient.subscriptions.update({ | |
where: { | |
stripeId: subscription.stripeId, | |
}, | |
data: { | |
stripeStatus: updatedSubscription.status, | |
name: 'default', | |
stripePrice: subscriptionItems[0].price.id, | |
quantity: subscriptionItems[0].quantity, | |
}, | |
}); | |
return new Response(null, { status: 204 }); | |
} catch (err) { | |
console.error(err); | |
return serverErrorResponse( | |
'There was a server error while updating the subscription. Contact the support team' | |
); | |
} | |
} | |
export async function DELETE(request: Request) { | |
try { | |
const token = (await getToken({ req: request as NextRequest }))!; | |
const subscription = (await prismaClient.subscriptions.findFirst({ | |
where: { userId: token.id as number, stripeStatus: 'active' }, | |
}))!; | |
if (!subscription) { | |
return Response.json( | |
{ message: 'User does not have any active subscriptions' }, | |
{ | |
status: 404, | |
} | |
); | |
} | |
if (subscription.endsAt !== null) { | |
return jsonErrorResponse( | |
ERROR_TYPE.DisallowedSubscriptionUpdate, | |
'Subscription is already marked for cancelation.', | |
422 | |
); | |
} | |
const stripe = getStripe(); | |
const updatedSubscription = await stripe.subscriptions.update( | |
subscription.stripeId, | |
{ | |
// This only 'marks' the subscription for cancelation. The subscription will actually be | |
// cancelled after the current billing cycle. | |
cancel_at_period_end: true, | |
} | |
); | |
await prismaClient.subscriptions.update({ | |
where: { | |
stripeId: subscription.stripeId, | |
}, | |
data: { | |
endsAt: new Date(updatedSubscription.cancel_at! * 1000), | |
}, | |
}); | |
return new Response(null, { | |
status: 204, | |
}); | |
} catch (err) { | |
console.error(err); | |
return serverErrorResponse( | |
'There was a server error while canceling the subscription. Contact the support team' | |
); | |
} | |
} |
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
// run this function when resume subscription is confirmed by user | |
async function handleResume() { | |
try { | |
const response = await fetch('/api/subscription/resume', { | |
method: 'PUT', | |
}); | |
} catch (err) { | |
console.error(err); | |
} | |
} |
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
import { | |
getStripe, | |
jsonErrorResponse, | |
serverErrorResponse, | |
} from '@/api-utils/utils'; | |
import prismaClient from '@/prisma/prisma-client'; | |
import { ERROR_TYPE } from '@/types/types'; | |
import { getToken } from 'next-auth/jwt'; | |
import { NextRequest } from 'next/server'; | |
export async function PUT(request: Request) { | |
try { | |
const token = (await getToken({ req: request as NextRequest }))!; | |
const subscription = (await prismaClient.subscriptions.findFirst({ | |
where: { userId: token.id as number, stripeStatus: 'active' }, | |
}))!; | |
if (!subscription) { | |
return jsonErrorResponse( | |
ERROR_TYPE.NoActiveSubscription, | |
'User does not have any active subscriptions', | |
422 | |
); | |
} | |
if (subscription.endsAt === null) { | |
return jsonErrorResponse( | |
ERROR_TYPE.DisallowedSubscriptionUpdate, | |
'Subscription is already resumed', | |
422 | |
); | |
} | |
const stripe = getStripe(); | |
await stripe.subscriptions.update(subscription.stripeId, { | |
cancel_at_period_end: false, | |
}); | |
await prismaClient.subscriptions.update({ | |
where: { | |
stripeId: subscription.stripeId, | |
}, | |
data: { | |
endsAt: null, | |
}, | |
}); | |
return new Response(null, { status: 204 }); | |
} catch (err) { | |
console.error(err); | |
return serverErrorResponse( | |
'There was a server error while resuming the subscription. Contact the support team' | |
); | |
} | |
} |
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
/********************************************************************************************** | |
This endpoint will give the payment that will be done at the end of the current billing cycle | |
i.e. for the next billing cycle | |
**********************************************************************************************/ | |
import { | |
formatInvoiceAmount, | |
getStripe, | |
serverErrorResponse, | |
} from '@/api-utils/utils'; | |
import prismaClient from '@/prisma/prisma-client'; | |
import { getToken } from 'next-auth/jwt'; | |
import { NextRequest } from 'next/server'; | |
export async function GET(request: Request) { | |
try { | |
const token = (await getToken({ req: request as NextRequest }))!; | |
const subscription = await prismaClient.subscriptions.findFirst({ | |
where: { userId: token.id as number, stripeStatus: 'active' }, | |
}); | |
if (subscription?.stripeStatus === 'active') { | |
const stripe = getStripe(); | |
const stripeSubscription = await stripe.subscriptions.retrieve( | |
subscription.stripeId | |
); | |
const nextPaymentDate = new Date( | |
stripeSubscription.current_period_end * 1000 | |
); | |
const user = (await prismaClient.users.findUnique({ | |
where: { | |
id: token.id as number, | |
}, | |
}))!; | |
// if subscription is marked for cancellation, then there is no upcoming invoice | |
if (subscription.endsAt !== null) { | |
return Response.json({ | |
nextPayment: null, | |
}); | |
} | |
const nextInvoice = await stripe.invoices.retrieveUpcoming({ | |
customer: user.stripeId!, | |
subscription: subscription.stripeId, | |
}); | |
return Response.json({ | |
nextPayment: { | |
date: nextPaymentDate, | |
amount: formatInvoiceAmount(nextInvoice.amount_due), | |
}, | |
}); | |
} else { | |
return Response.json({ | |
nextPayment: null, | |
}); | |
} | |
} catch (e) { | |
console.error(e); | |
return serverErrorResponse( | |
'There was a server error while fetching the next payment details. Contact the support team' | |
); | |
} | |
} |
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
/********************************************************************************************** | |
Get all paid invoices for the user | |
**********************************************************************************************/ | |
import { | |
formatInvoiceAmount, | |
getStripe, | |
serverErrorResponse, | |
} from '@/api-utils/utils'; | |
import prismaClient from '@/prisma/prisma-client'; | |
import { getToken } from 'next-auth/jwt'; | |
import { NextRequest } from 'next/server'; | |
export async function GET(request: Request) { | |
try { | |
const token = (await getToken({ req: request as NextRequest }))!; | |
const user = (await prismaClient.users.findUnique({ | |
where: { id: token.id as number }, | |
}))!; | |
if (!user.stripeId) { | |
return Response.json({ invoices: null }); | |
} | |
const stripe = getStripe(); | |
const stripeInvoices = await stripe.invoices.list({ | |
customer: user.stripeId, | |
status: 'paid', | |
}); | |
const invoices = stripeInvoices.data.map(invoice => ({ | |
id: invoice.id, | |
createdAt: new Date(invoice.created * 1000), | |
amount: formatInvoiceAmount(invoice.total), | |
status: invoice.status, | |
url: invoice.hosted_invoice_url, | |
})); | |
return Response.json({ invoices: invoices.length > 0 ? invoices : null }); | |
} catch (e) { | |
console.error(e); | |
return serverErrorResponse( | |
'There was a server error fetching the user invoices. Contact the support team' | |
); | |
} | |
} |
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
/********************************************************************************************** | |
We can use stripe webhook for handling asynchronous stripe events that we don't have control | |
over, like subscription update after end of a billing cycle, canceling a subscription at the | |
end of billing cycle etc. | |
**********************************************************************************************/ | |
import { formatInvoiceAmount, getStripe } from '@/api-utils/utils'; | |
import prismaClient from '@/prisma/prisma-client'; | |
import Stripe from 'stripe'; | |
export async function POST(request: Request) { | |
const stripe = getStripe(); | |
const sig = request.headers.get('stripe-signature')!; | |
const payload = await request.text(); | |
let event; | |
try { | |
event = stripe.webhooks.constructEvent( | |
payload, | |
sig, | |
process.env.STRIPE_WEBHOOK_SECRET! | |
); | |
} catch (err: any) { | |
console.error(err); | |
return new Response(`Webhook Error: ${err.message}`, { | |
status: 400, | |
}); | |
} | |
if (event.type === 'checkout.session.completed') { | |
await handleCheckoutSessionCompletion(event); | |
} else if (event.type === 'customer.subscription.updated') { | |
await handleSubscriptionUpdate(event); | |
} else if (event.type === 'customer.subscription.deleted') { | |
await handleSubscriptionDelete(event); | |
} else if (event.type === 'invoice.payment_succeeded') { | |
await handleInvoicePaymentSuccess(event); | |
} else if (event.type === 'payment_method.attached') { | |
await handlePaymentMethodAttach(event); | |
} | |
return new Response(null, { | |
status: 204, | |
}); | |
} | |
async function handlePaymentMethodAttach( | |
event: Stripe.PaymentMethodAttachedEvent | |
) { | |
console.log('payment_method.attached event occurred'); | |
const stripe = getStripe(); | |
const paymentMethod = event.data.object; | |
const customer = (await stripe.customers.retrieve( | |
paymentMethod.customer as string | |
)) as Stripe.Customer; | |
if (paymentMethod.card) { | |
await prismaClient.users.update({ | |
where: { | |
stripeId: paymentMethod.customer as string, | |
email: customer.email!, | |
}, | |
data: { | |
pmType: paymentMethod.card.brand, | |
pmLastFour: paymentMethod.card.last4, | |
pmExpiration: `${paymentMethod.card.exp_month}/${paymentMethod.card.exp_year}`, | |
}, | |
}); | |
} | |
} | |
async function handleInvoicePaymentSuccess( | |
event: Stripe.InvoicePaymentSucceededEvent | |
) { | |
console.log('invoice.payment_succeeded event occurred'); | |
// create receipts for successfull invoice payment | |
try { | |
const invoice = event.data.object; | |
const user = (await prismaClient.users.findFirst({ | |
where: { | |
stripeId: invoice.customer as string, | |
}, | |
}))!; | |
await prismaClient.receipts.create({ | |
data: { | |
userId: user.id, | |
providerId: invoice.id, | |
amount: formatInvoiceAmount(invoice.amount_due), | |
tax: `$${invoice.tax ?? '0.00'}`, | |
paidAt: new Date(invoice.status_transitions.paid_at! * 1000), | |
createdAt: new Date(), | |
}, | |
}); | |
} catch (err) { | |
console.error(err); | |
} | |
} | |
async function handleCheckoutSessionCompletion( | |
event: Stripe.CheckoutSessionCompletedEvent | |
) { | |
try { | |
console.log('chekout.session.completed event occurred'); | |
// update the subscription database and also the users database (for billing details) | |
const sessionData = event.data.object; | |
const stripe = getStripe(); | |
if ( | |
sessionData.status === 'complete' && | |
sessionData.mode === 'subscription' | |
) { | |
const customer = (await stripe.customers.retrieve( | |
sessionData.customer as string | |
)) as Stripe.Customer; | |
const billingCountry = customer.address?.country ?? null; | |
const billingState = customer.address?.state ?? null; | |
const billingCity = customer.address?.city ?? null; | |
const billingPostalCode = customer.address?.postal_code ?? null; | |
const billingAddress = customer.address?.line1 ?? null; | |
const billingAddressLine2 = customer.address?.line2 ?? null; | |
const user = await prismaClient.users.update({ | |
data: { | |
billingCountry, | |
billingState, | |
billingCity, | |
billingPostalCode, | |
billingAddress, | |
billingAddressLine2, | |
}, | |
where: { | |
email: customer.email!, | |
}, | |
}); | |
const stripeSubscription = (await stripe.subscriptions.retrieve( | |
sessionData.subscription as string | |
)) as Stripe.Subscription; | |
const subscriptionItems = stripeSubscription.items.data; | |
// create subscription | |
const subscription = await prismaClient.subscriptions.create({ | |
data: { | |
stripeId: sessionData.subscription as string, | |
stripeStatus: stripeSubscription.status, | |
name: 'default', | |
userId: user.id, | |
stripePrice: subscriptionItems[0].price.id, | |
quantity: subscriptionItems[0].quantity, | |
createdAt: new Date(), | |
}, | |
}); | |
} | |
} catch (err) { | |
console.error(err); | |
} | |
} | |
async function handleSubscriptionUpdate( | |
event: Stripe.CustomerSubscriptionUpdatedEvent | |
) { | |
const subscription = event.data.object; | |
console.log('customer.subscription.updated event occurred'); | |
try { | |
if (['past_due', 'canceled'].includes(subscription.status)) { | |
await prismaClient.subscriptions.update({ | |
where: { | |
stripeId: subscription.id, | |
}, | |
data: { | |
stripeStatus: subscription.status, | |
updatedAt: new Date(), | |
}, | |
}); | |
} | |
} catch (err) { | |
console.error(err); | |
} | |
} | |
async function handleSubscriptionDelete( | |
event: Stripe.CustomerSubscriptionDeletedEvent | |
) { | |
console.log('customer.subscription.deleted event occurred'); | |
try { | |
// update subscription status to 'canceled' | |
const subscription = event.data.object; | |
await prismaClient.subscriptions.update({ | |
where: { | |
stripeId: subscription.id, | |
}, | |
data: { | |
stripeStatus: subscription.status, | |
}, | |
}); | |
} catch (err) { | |
console.error(err); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment