Languages | Tools Used | Time Saved
Node.js, HTML Stripe 3 weeks → 40 mins
Goodies
Live Demo
This is the Part 2 in the series of guides on adding, managing, and keeping track of subscription payments using Stripe an Mongo for your SaaS app.
Subscription Payments are the bread and butter of a SaaS application; it's how you start generating revenue. In this guide, we will:
- Save customer data in a Mongo database
- Display Account Dashboard view
- Set up the Stripe Webhook to receive events from the Stripe Dashboard
- Update plan details when customer payment goes through
Start Local Mongo DB
mongod
A local Mongo instance should start up on mongo://localhost:27017
Add MongoDB Connection to App
Add the MongoDB connection to app.js
:
const mongoose = require('mongoose')
const db = 'mongodb://localhost:27017/users'
mongoose.connect(
db,
{
useUnifiedTopology: true,
useNewUrlParser: true,
useFindAndModify: false,
useCreateIndex: true
},
(error) => {
if (error) console.log(error)
}
)
Create User Schema
For each user, we will save:
- email: The email of the user
- billingID: The Stripe Customer ID
- plan: The plan type the customer is currently subscribed to: None, Basic, or Pro
- hasTrial: If the customer is currently on a trial
- endDate: The date when the trial or the plan will renew
Create the User Model
Create a new file called, src/user/user.model.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const userSchema = new Schema({
email: String,
billingID: String,
plan: { type: String, enum: ['none', 'basic', 'pro'], default: 'none' },
hasTrial: { type: Boolean, default: false },
endDate: { type: Date, default: null }
})
const userModel = mongoose.model('user', userSchema, 'user')
module.exports = userModel
We will also add a User Service by creating src/user/user.service.js
const addUser = (User) => ({ email, billingID, plan, endDate }) => {
if (!email || !billingID || !plan) { throw new Error('Missing Data. Please provide values for email, billingID, plan') }
const user = new User({ email, billingID, plan, endDate })
return user.save()
}
const getUsers = (User) => () => {
return User.find({})
}
const getUserByEmail = (User) => async (email) => {
return await User.findOne({ email })
}
const getUserByBillingID = (User) => async (billingID) => {
return await User.findOne({ billingID })
}
const updatePlan = (User) => (email, plan) => {
return User.findOneAndUpdate({ email, plan })
}
module.exports = (User) => {
return {
addUser: addUser(User),
getUsers: getUsers(User),
getUserByEmail: getUserByEmail(User),
updatePlan: updatePlan(User),
getUserByBillingID: getUserByBillingID(User)
}
}
We can now inject the model into the service by adding src/user/index.js
const User = require('./user.model')
const UserService = require('./user.service')
module.exports = UserService(User)
Now when our user logs in using their email address, in addition to creating a Stripe Customer, we can save their information in a database.
We are now saving the email instead of the customer ID in the user session.
In app.js
:
const UserService = require('./src/user')
app.post("/login", async function (req, res) {
const { email } = req.body;
console.log("email", email);
let customer = await UserService.getUserByEmail(email);
let customerInfo = {};
if (!customer) {
console.log(`email ${email} does not exist. Making one. `);
try {
customerInfo = await Stripe.addNewCustomer(email);
customer = await UserService.addUser({
email: customerInfo.email,
billingID: customerInfo.id,
hasTrial: false,
plan: "none",
endDate: null
});
console.log(
`A new user signed up and addded to DB. The ID for ${email} is ${JSON.stringify(
customerInfo
)}`
);
console.log(`User also added to DB. Information from DB: ${customer}`);
} catch (e) {
console.log(e);
res.status(200).json({ e });
return;
}
}
req.session.email = email;
res.redirect('/account')
})
Display Dashboard View
A customer should be able to view their account information on the front end when they succesfully log in. Because we are saving everything in a database, it is trivial to create such a view.
Let's update our /account
endpoint in app.js
to get the customer information from the database.
app.get('/account', async function (req, res) {
const { email } = req.session
const customer = await UserService.getUserByEmail(email)
res.render('account.ejs', { customer })
})
We can use EJS to render customer information when they are already subscribed. If not, we can present the usual Buy a Plan
view. In our views/account.ejs
<html>
<head>
</head>
<body>
<div>
<h1>Account Dashboard</h1>
<p>Hi <%- customer.email %>
</p>
<%if (customer.plan=="none" ) { %>
<p>
You are currently not on any plan. Purchase a subscription below.
</p>
<input type="radio" id="basic" name="product" value="basic" />
<label for="basic">Basic for $10</label><br />
<input type="radio" id="pro" name="product" value="pro" />
<label for="pro">Pro for $12</label><br />
<button id="checkout-button" type="submit">
Buy now
</button>
<% } else{ %>
<p>
You are currently on the <%- customer.plan %> plan %>
</p>
<%if (customer.hasTrial) { %>
<p>Trial active until <%- customer.endDate %>
</p>
<% } else{ %>
<p>Trial inactive. Plan will end on <%- customer.endDate %>
</p>
<% } %>
<% } %>
</div>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
crossorigin="anonymous"></script>
<script type="text/javascript" src="https://js.stripe.com/v3/"></script>
<script>
var customer = <%- JSON.stringify(customer) %>;
console.log(customer);
</script>
<script type="text/javascript" src="./js/account.js"></script>
</body>
<html>
Once again, the view by itself does not look great so we made a better styled version and added to the Github project. Here is the HTML and the CSS.
We can also create our checkout session with the customer information pre-filled. Edit src/js/account.js
and add customer Billing ID.
checkoutButton.click(function () {
const product = $("input[name='product']:checked").val()
fetch('/checkout', {
// ...
body: JSON.stringify({
product,
customerID: customer.billingID
})
})
.then((result) => result.json())
.then(({ sessionId }) => stripe.redirectToCheckout({ sessionId }))
})
Edit app.js
with,
app.post('/checkout', async (req, res) => {
const { customerID, product } = req.body
const price = productToPriceMap[product.toUpperCase()]
const session = await Stripe.createCheckoutSession(customerID, price)
res.send({ sessionId: session.id })
})
Configure local webhook
Great! We can now add subscription plans for our customers. But we also have to add this information to our database. It's better if Stripe tells us when a subscription has been created successfully than us trying to guess.
This is exactly what the Stripe webhook is used for. We subscribe our Stripe account's webhook and any time an event occurs on the platform, we get an event notification. We can then use the information in the event to trigger functionalities on our application i.e. updating the database.
A few useful events that we can access with Stripe are customer.subscription.created
, invoice.paid
and more.
Download Stripe CLI from the official Stripe website here.
We can set up the webhook locally to capture those events.
stripe login
stripe listen --forward-to localhost:4242/webhook
We will use the Webhook Secret
in the next step to listen to events.
Add a createWebhook in src/connect/stripe.js
with STRIPE_WEBHOOK_SECRET
:
const STRIPE_WEBHOOK_SECRET = 'wh_xx'
// ..
const createWebhook = (rawBody, sig) => {
const event = Stripe.webhooks.constructEvent(
rawBody,
sig,
STRIPE_WEBHOOK_SECRET
)
return event
}
module.exports = {
getCustomerByID,
addNewCustomer,
createWebhook
}
Let's edit our app.js
app.use("/webhook", bodyParser.raw({ type: "application/json" }))
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }))
app.post('/webhook', async (req, res) => {
let event
try {
event = Stripe.createWebhook(req.body, req.header('Stripe-Signature'))
} catch (err) {
console.log(err)
return res.sendStatus(400)
}
const data = event.data.object
console.log(event.type, data)
res.sendStatus(200)
})
Notice the bodyParser.raw
line needs to placed before the /webhook
endpoint otherwise Stripe will throw an error.
Start the server.
npm start
Go to the Stripe dashboard. A webhook event will be fired when you Add a New Customer
. The event will be caught by our application and printed in the terminal.
customer.subscription.created { id: 'sub_IahKmOJSUeCJ5b',
object: 'subscription',
application_fee_percent: null,
billing_cycle_anchor: 1608247822,
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
collection_method: 'send_invoice',
created: 1608247822,
current_period_end: 1610926222,
current_period_start: 1608247822,
customer: 'cus_IV5wklBSZWztsG',
days_until_due: 30,
default_payment_method: null,
default_source: null,
default_tax_rates: [],
discount: null,
ended_at: null,
items:
{ object: 'list',
data: [ [Object] ],
has_more: false,
total_count: 1,
url: '/v1/subscription_items?subscription=sub_IahKmOJSUeCJ5b' },
latest_invoice: 'in_1HzVvOKelOFxScryYDXXoBEj',
livemode: false,
metadata: {},
next_pending_invoice_item_invoice: null,
pause_collection: null,
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
plan:
{ id: 'price_1Hu1OhKelOFxScryyLwJXiWV',
object: 'plan',
active: true,
aggregate_usage: null,
amount: 1200,
amount_decimal: '1200',
billing_scheme: 'per_unit',
created: 1606938835,
currency: 'cad',
interval: 'month',
interval_count: 1,
livemode: false,
metadata: {},
nickname: null,
product: 'prod_IV1RHfJZ3sQ8GM',
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: 'licensed' },
quantity: 1,
schedule: null,
start_date: 1608247822,
status: 'active',
transfer_data: null,
trial_end: null,
trial_start: null }
Update customer plan
When the customer buys a new subscription, we will see a customer.subscription.created
. We can use this information to update the customer plan in the database.
app.post('/webhook', async (req, res) => {
let event
try {
event = Stripe.createWebhook(req.body, req.header('Stripe-Signature'))
} catch (err) {
console.log(err)
return res.sendStatus(400)
}
const data = event.data.object
console.log(event.type, data)
switch (event.type) {
case 'customer.subscription.created': {
console.log('new subscription added' + JSON.stringify(data))
const user = await UserService.getUserByBillingID(data.customer)
if (data.plan.id === productToPriceMap.BASIC) {
console.log('You are talking about basic product')
user.plan = 'basic'
}
if (data.plan.id === productToPriceMap.PRO) {
console.log('You are talking about pro product')
user.plan = 'pro'
}
const isOnTrial = data.status === 'trialing'
if (isOnTrial) {
user.hasTrial = true
user.endDate = new Date(data.current_period_end * 1000)
} else if (data.status === 'active') {
user.hasTrial = false
user.endDate = new Date(data.current_period_end * 1000)
}
await user.save()
break
}
default:
}
res.sendStatus(200)
})
We also need to check if the trial should be over. We don't have to check it asynchronously in the background by cron processes, that would be overkill. A better way to do it would be the customer logs in, we check if the trial should stay active or not.
In app.js
app.post("/login", async function (req, res) {
const { email } = req.body;
console.log("email", email);
let customer = await UserService.getUserByEmail(email);
let customerInfo = {};
if (!customer) {
// ..
} else {
const isTrialExpired =
customer.plan !== 'none' && customer.endDate < new Date().getTime()
if (isTrialExpired) {
console.log('trial expired')
customer.hasTrial = false
customer.save()
} else {
console.log(
'no trial information',
customer.hasTrial,
customer.plan !== 'none',
customer.endDate < new Date().getTime()
)
}
}
req.session.email = email
res.send(customerInfo)
})
Done!
In this guide we learned how to:
- Save customer data in a Mongo database
- Set up the Stripe Webhook to receive events from the Stripe Dashboard
- Update plan details when customer payment goes through
Our customers can buy subscriptions but they cannot cancel or update their plans. We will look inot implementing that in the next guide.