Subscription Payments #2: Keeping track of Customer Billing information using Mongo and Stripe Webhooks

Subscription Payments are the bread and butter of a SaaS application. In this guide, we save customer billing information in Mongo and use Stripe webhooks to update plan details.

Subscription Payments #2: Keeping track of Customer Billing information using Mongo and Stripe Webhooks

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:

  1. Save customer data in a Mongo database
  2. Display Account Dashboard view
  3. Set up the Stripe Webhook to receive events from the Stripe Dashboard
  4. Update plan details when a customer pays successfully

Start a Local Mongo DB

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 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 (default), Basic, or Pro
  • hasTrial: If the customer is currently on a trial
  • endDate: The date when the trial or the plan renews

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)

Save user sessions

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')
})

UI - Account Dashboard

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>

The view again by itself doesn't look great, so we made a better styled version and added to the Github project for you to use. Here is the HTML and the CSS.

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 })
})

Configure local webhook

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

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 }

Update customer plan

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!

Next Steps

In this guide we learned how to:

  1. Save customer data in a Mongo database
  2. Set up the Stripe Webhook to receive events from the Stripe Dashboard
  3. Update plan details when customer payment goes through

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.