Grow your SaaS organically with Content Marketing.
Try for free →This is Part 2 in the series of guides on adding, managing, and keeping track of subscription payments using Stripe and Mongo for your SaaS app.
Subscription Payments are the bread and butter of a SaaS application; it's how you start generating revenue. In a well-implemented system, users need to first be able to change or cancel their plans, and second, undergo a trial phase to see if they would even like to pay for premium features. Typically, it's a hassle to set these up. In this set of guides, we will go through all the steps required to build a complete Subscription Payments system for your SaaS app.
In Part 2, we will:
We need a database. MongoDB is a very good choice. Download the community version here.
In Part 5 of the guide available here, we will use MongoDB Cloud Atlas that lets us host a MongoDB instance in the cloud. Run,
mongod
A local Mongo instance should start up on mongo://localhost:27017
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);
}
);
For each user, we will save:
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);
When the user logs in using their email address, in addition to creating a Stripe Customer, we can save their information in the database as well.
Notice that we are now saving the email instead of the customer ID in the user session so we can look up the customer profile in the database when a new request comes in.
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");
});
A customer should be able to view their account information on the front-end when they log in successfully. Since we are saving everything in a database, it's 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);
if (!customer) {
res.redirect("/");
} else {
res.render("account.ejs", {
customer,
});
}
});
We can use EJS to render customer information if they are already subscribed. If
they are 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></html>
</html>
The view again by itself doesn't look great, so we made a better-styled version and added it to the Github project for you to use. Here is the
and the
.
Dashboard for a logged in user For better UX, we should create the checkout session with the customer information pre-filled. Edit
src/js/account.js
to add customer Billing ID and the product the customer is buying.
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,
})
);
});
Create the session with the Customer ID and the Product ID by editing 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 });
});
Great! We can now add subscription plans for our customers. The trouble is how and when do we update our customer information in the database.
One solution is to update the information in the /checkout
endpoint. This is easy to do but not recommended. Consider a scenario where a customer triggers the checkout flow but errors out. This will wrongly update the database.
A better solution is to wait for Stripe to confirm that a particular plan has been successfully assigned to a customer and then update the database accordingly. Stripe webhooks are perfect for this scenario. Whenever an event occurs on Stripe, a real-time notification is sent to the webhook endpoint. We can subscribe to this endpoint to trigger various 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. The customer.subscription.created
event is what we're currently interested in.
Download Stripe CLI from the official Stripe website here.
We can set up the webhook locally to capture those events by running,
./stripe login
./stripe listen --forward-to localhost:4242/webhook
Connecting Stripe webhook locally Stripe returns us a signing secret for use locally. We will use the
Webhook Secret
in the next step to listen to events.
Note that the above steps are only needed to capture the Stripe events when working locally on your machine. Stripe also lets you specify a hosted URL to send events to when you're deployed in Production. This is covered in Part 5 of this guide, available here.
Let's 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,
};
We'll set up a /webhook
endpoint that Stripe will send events to. 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 be placed before the /webhook
endpoint, otherwise, Stripe will throw an error. Stripe webhook requires the raw request body to verify the signature.
Start the server by running,
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
}
When the customer buys a new subscription, we will see a customer.subscription.created
. We can catch this event and use the attached 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": {
const user = await UserService.getUserByBillingID(data.customer);
if (data.plan.id === productToPriceMap.BASIC) {
user.plan = "basic";
}
if (data.plan.id === productToPriceMap.PRO) {
user.plan = "pro";
}
user.hasTrial = true;
user.endDate = new Date(data.current_period_end * 1000);
await user.save();
break;
}
default:
}
res.sendStatus(200);
});
Awesome! Now our database is in sync with the actual customer billing information.
We also need to check if the trial should be over. We don't have to check it asynchronously in the background with a cron process, that would be overkill.
A better way to do it would be to check if the trial should stay active or not when the customer logs in.
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) {
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:
Our customers can now buy subscriptions but they cannot cancel or update their plans yet. We will look into implementing this in the next guide.
I'm building a new SaaS to automate content marketing for your SaaS
Tools for SaaS Devs