Charging SaaS Tenants based on the number of users

Let's extend Makerkit to charge Stripe customers based on the number of users in an organization

Charging a tenant according to the number of users they add is a popular pricing scenario for SaaS.

For example, a SaaS could offer the following subscription plans:

  1. A basic plan for $9, which allows only one user, and charges an additional 9$ for every 5 users invited

  2. A pro plan for 29$ per user, which includes 5 users, and charges an additional 29$ for every 5 users invited

In this blog post, we extend Makerkit to implement per-seat billing with Stripe and Next.js.

How to implement per-seat subscriptions with Stripe?

Stripe's subscriptions use a field called quantity which can be used in different ways. In our case, we can use this field to bill a customer's organization based on how many users they invite.

So, if we billed $9 for each new user, we could simply increment the quantity field by 1. Stripe also allows us to transform the quantity field: for example, assuming we wanted to allow organizations to have 5 users before we increment their billing price, we would use transform_quantity[divide_by]=5.

This means that for up to 5 users, the organization will be billed 9$. Instead, between 6 and 10 users, they will be billed $18, and so on.

To learn more about this, check out the Stripe documentation.

You can also do the same using Stripe's Dashboard. In fact, we can use Package pricing as a pricing model, and set a price for N number of units:

<Image src="/assets/images/posts/stripe-create-package-subscription.webp" width="1124" height="1414" />

In the above, we're configuring our subscription to charge customers $10 for every five units: in our scenario, a unit is a member added to the organization.

Extending Makerkit to support per-seat subscriptions

By default, the Makerkit SaaS boilerplate does not handle per-seat Subscriptions, so we're going to go deep into the Makerkit code to show you how you can implement it yourself.

The main parts we're going to change are:

  1. Creating a price that supports pricing per unit (as seen above)
  2. Setting the correct quantity field when creating a subscription
  3. Increasing the quantity field of a subscription when members are added
  4. Decreasing the quantity field of a subscription when members are removed

Adding plan to Configuration

Makerkit has a very simple built-in plan selector generated automatically from the configuration's plans. To test the integration, I created a new plan with Stripe, and I replaced the existing ones using the src/configuration.ts file:

plans: [
{
name: 'Per Seat Subscription',
description: '$10 for each 5 users',
price: '$10/5 users',
stripePriceId: 'price_**********',
},
],

Of course, when you create your plan, remember to replace the stripePriceId property with the actual ID.

<Image src="/assets/images/posts/stripe-plan-selector-per-seat-subscription.webp" width="1174" height="562" />

When creating a Checkout Session, the user will see the below:

<Image src="/assets/images/posts/stripe-checkout-per-seat-subscription.webp" width="968" height="578" />

Creating a Subscription

In the Next.js Makerkit boilerplate, a Stripe subscription is created at src/lib/stripe/create-checkout.ts when an organization completes a Stripe checkout session.

Below is a snippet that creates a Stripe checkout session for a subscription, with quantity set to 1, as defined in Makerkit: we need to update it so that the quantity field reflects the number of users added to the organization.

const mode: Stripe.Checkout.SessionCreateParams.Mode = 'subscription';
// get stripe instance
const stripe = await getStripeInstance();
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
quantity: 1,
price: params.priceId,
};
return stripe.checkout.sessions.create({
mode,
customer,
line_items: [lineItem],
success_url: successUrl,
cancel_url: cancelUrl,
client_reference_id: clientReferenceId,
});

First, we want to be able to retrieve the number of users in an organization. To do so, let's add a new function getOrganizationMembers to the file src/lib/server/queries.ts.

export async function getOrganizationMembers(organizationId: string) {
const ref = await getOrganizationById(organizationId);
const data = ref?.data();
if (!data) {
throw new Error(`Organization with ID ${organizationId} not found`);
}
return data.members;
}

Going back to create-checkout.ts, let's update the quantity with the number of users an organization has:

const members = await getOrganizationMembers();
const numberOfMembers = Object.values(members).length;
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
quantity: numberOfMembers,
price: params.priceId,
};

Above, we retrieve the current number of members belonging to the organization and assign it as quantity.

Updating quantity when a member is added

Whenever a user is added to the organization, we want to increment the quantity field of the subscription, so that Stripe will be able to automatically set the correct billing amount charged to the customer.

This can be done in two places:

  • when the invitation is sent
  • when the invitation is accepted

In this case, we'll do it when the invitation is accepted. Therefore, we will update the POST handler of the organizations/members endpoint.

async function increaseSubscriptionQuantity(
subscriptionId: string
) {
const stripe = await getStripeInstance();
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// As we add only 1 line item during the subscription creation
// we can safely select the first in the array
//
// NB: if you customize the line items, you have to find a different way
// to select the correct line item!
const lineItem = subscription.items.data[0];
if (!lineItem) {
throw new Error(
`Subscription with ID ${subscriptionId} has no line items?`
);
}
const currentQuantity = lineItem.quantity ?? 0;
const quantity = currentQuantity + 1;
return stripe.subscriptionItems.update(lineItem.id, {
quantity,
});
}

Now, we need to call the function defined above in the members.ts API controller:

if (method === 'POST') {
const { code } = getBodySchema().parse(req.body);
// here we retrieve the subscription ID
const organization = await getOrganizationById(organizationId);
const subscriptionId = organization.data()?.subscription?.id;
// we require a subscription ID to invite a user!
if (!subscriptionId) {
return forbiddenException(res);
}
// we accept the invitation
await acceptInviteToOrganization({ code, userId });
// once successful, we increase the quantity of the line item
await increaseSubscriptionQuantity(subscriptionId);
}

<Alert type={'info'}> As you can see above, we require a subscription to have an user accept an invite. This is sort of normal, since we need to update the quantity. This means you may need to lock invites to paying organizations (or other strategies to make sure the above works). </Alert>

When the number of users exceeds 5, the subscription will automatically increase the monthly amount to $20:

<Image src='/assets/images/posts/quantity-subscription-20.webp' width={"2028"} height={"548"} />

Updating quantity when a member is removed

Instead, when a user is removed from the organization, we want to decrease the quantity field. To do so, simply define nearly the same function as increaseSubscriptionQuantity, but with a clear difference: instead of summing the quantity, we will subtract it by 1.

const currentQuantity = lineItem.quantity ?? 0;
const quantity = currentQuantity - 1;

Otherwise, the body of the function will be identical.

Now, we want to open the file pages/api/organizations/[id]/members/[member].ts. Here, we can see a branch where we handle a DELETE request, which we use to remove a member from an organization. When this happens, we can update the subscription:

if (method === 'DELETE') {
const organizationId = payload.organizationId;
// retrieve subscription ID
const organization = await getOrganizationById(organizationId);
const subscriptionId = organization.data()?.subscription?.id;
// remove member from organization
await removeMemberFromOrganization(payload);
if (subscriptionId) {
// once successful, we decrease the quantity of the line item
await decreaseSubscriptionQuantity(subscriptionId);
} else {
logger.warn(payload, `Subscription for organization ${organizationId} was not found`);
}
logger.info(payload, `User removed from organization`);
return res.send({ success: true });
}

Assuming the number of users in the organization is now 6, the subscription amount will go back to $10:

<Image src='/assets/images/posts/quantity-subscription-10.webp' width={"2084"} height={"614"} />

🎉 And that's it! We have now implemented per-seat billing with Stripe and our Next.js API.

Previewing Invoices

It can be useful to preview the next payment to your customers after (or before) they add a new member, therefore increasing their spending.

To do so, we can use Stripe's retrieveUpcoming method to preview the next invoice given a list of subscription items.

function previewNextSubscriptionInvoice(
params: {
customerId: string;
subscriptionId: string;
}
) {
const proration_date = Math.floor(Date.now() / 1000);
const subscription =
await stripe.subscriptions.retrieve(params.subscriptionId);
// See what the next invoice would look like with a price switch
// and proration set:
const item = subscription.items.data[0];
const items = [{
id: item.id,
price: item.price,
// use "item.quantity - 1" if the member is removed
quantity: item.quantity + 1,
}];
return stripe.invoices.retrieveUpcoming({
customer: params.customerId,
subscription: params.subscriptionId,
subscription_items: items,
subscription_proration_date: proration_date,
});
}

The invoice object is rich in information that you can display, but the most important may be subtotal, which you could display to customers to be transparent about what they will pay.

Managing Unsubscriptions

When an organization unsubscribes, or lowers to a more restricted plan, you should think about how to handle the fact that the organization may have invited more users than your plan allows.

First, we need to listen to an unsubscription webhook: we actually already do. If you visit pages/api/stripe/webhook.ts, check out the following branch:

case StripeWebhooks.SubscriptionUpdated: {
const subscription = event.data.object as Stripe.Subscription;
await onSubscriptionUpdated(subscription);
// YOUR LOGIC GOES HERE
break;
}

Here, we can write the logic to handle what happens when an organization unsubscribes from a plan. What happens depends on you and how you prefer to handle it.

Summary

In this blog post, we have extended the Next.js SaaS Boilerplate so that our SaaS can charge customers based on the number of users they invite to their projects.

To summarize what we have done:

  1. We have created a price using the Stripe Dashboard that allows us to charge customers using Package pricing
  2. When the subscription gets created, we set the quantity field based on the current number of members in the organization. In this case, we're assuming the organization starts without a subscription (as is by default).
  3. When a member is added to the organization, we increase the quantity field
  4. When a member is removed from the organization, we decrease the quantity field

Unfortunately, there is more to do to finalize the logic above, and it really depends on your preferences.

Final Considerations

To finalize and improve the logic explained above, you have to make a few choices.

Restrict invitations

Should you restrict invites to organizations with a valid subscription? Probably. Alternatively, when accepting an invitation, we can check if the organization is within a free quota (ex. 5 users)

What happens when an organization unsubscribes?

You may not be able to prevent a cancellation from the Stripe Portal.

Therefore, you may need a way to gate organizations from using more resources than the plan allows. One of the ways could be to add some logic to withAppProps and check if the customer is on a plan: if not, force redirect to the subscription page.