Building it yourself: How to implement prepaid credits

Kshitij Grover

In our previous blog post, we outlined how we would set up metered billing for our theoretical company PixelMate, an image processing platform. In the post, we focused on a common pricing approach for many startups: a couple of plans for self-serve users looking to get started on the platform. In this post, we’ll explore how to implement another common billing need: prepaid credits.

As PixelMate’s business grows, we learn that enterprise customers want more predictability in their buying process. Although these larger customers are willing to spend more on our platform, their procurement teams don’t love the month-to-month variability in spend and our sales team is facing an uphill battle in signing new business contracts with the current plans. Instead, these customers want to commit to a certain spend and draw-down from this bucket each month based on usage, just like they would on Snowflake and Databricks.

Instead of a monthly allocation of included images, our business teams want to offer PixelMate “image credits” — a large purchase in exchange for a discount— which can be utilized over the course of a contract term.

Here’s how an example 2-year enterprise contract might look:

  • A purchase of 5,000,000 processing credits at a discounted rate of $0.03/credit. Each credit grants one successful image processing call to our system. The credits should expire at the end of the 2 years.
  • When credits are depleted, on-demand image processing will cost $0.05/image processed. On-demand overages will be assessed monthly.

As a starting point to this system, we’ll use the architecture we ended up with in our last post. As a reminder, we have a Stripe webhook consumer that consumes the invoice from Stripe Billing and amends it with the usage for the subscription.

Modeling prepaid in Stripe

Although we use Stripe Billing for our self-serve customers, Stripe doesn’t support prepaid subscriptions so we won’t be able to use the Subscription concept directly to represent a one-off purchase of credits. Instead, our system will be responsible for creating a separate Stripe invoice when the subscription is provisioned.

Note, however, that we still need a subscription to exist in Stripe to ensure that we invoice for any monthly on-demand overages if/when they happen. Although we could schedule this recurring invoice in our own system, it’s simpler to have Stripe trigger the invoice to be consistent with our self-serve plans and to capture events like a subscription cancellation. To accomplish this, we’ll set up a Stripe subscription with a $0 product that bills monthly. We don’t want to keep any $0 line items on our final invoice, but it lets us continue to treat Stripe as the source of truth for our invoicing cadence.

Modeling the credit balance

In order to understand how much an enterprise customer should be charged, it’s no longer sufficient for us to only query over the image processing requests that live in Redshift. Instead, we need to keep track of the new customer state, which is their remaining credit balance. To start, we’ll update each customer’s credit balance when an invoice is issued, reflecting the latest usage in the billing period. If, after subtracting the new usage, we still have a positive set of credits, we won’t add any charges to the invoice.

Let’s add this state to our data model, making sure we can keep track of the credit balance as well as the overage rate. We want to make sure we have a record of updates to the customer’s credit balance, so we’ll also add an audit entry every time it is updated:


Customer: 
  id: str
  name: str
  ...
  credits_overage_rate: Decimal 

CreditBalance:
  id: str
  customer_id: ForeignKey[Customer.id]
  remaining_balance: Decimal

BalanceUpdateAuditRecord:
  id: str
  action_time: timestamp
  credit_balance_id: ForeignKey[CreditBalance.Id]
  previous_balance: Decimal
  new_balance: Decimal

Now, when we receive an invoice creation webhook from Stripe, we’ll take the following steps (with all updates happening in a single transaction on the relational table):

  1. From the metadata on the Stripe Invoice, look up the corresponding PixelMate customer. 
  2. Query Redshift for the customer’s usage in the relevant billing period, determined by the bounds of the Invoice line item. We’ll need to ensure that all the request records for the invoicing period are already in the system to get an accurate picture.
  3. Query the relational database for the customer’s CreditBalance record, specifically noting the remaining balance.
  4. If the remaining balance is positive, deduct credits corresponding to the customer’s usage, without letting the balance go negative.
  5. Apply the contracted overage rate to the remaining credits and add this to the corresponding invoice.
  6. Add a new BalanceUpdateAuditRecord reflecting the change in the customer’s credit balance (it’s likely we’d also want to have some metadata attributing the actor that made the change).

Handling credit balance changes

Although the monthly deduction approach works for the basic invoicing use case as outlined above, we haven’t yet modeled two key pieces: the expiration behavior of credits and purchasing additional credits.

As our contract noted earlier, these credits should expire after 2 years from the initial purchase: this is both a mechanism to encourage timely adoption of PixelMate as well as an important clause for accounting reasons (we’ll come back to this in a minute.) 

In order to model credit expiration, we can add an expiration time to our data model. Looking ahead to having multiple purchases which may be made many months later, let’s outline a broader refactor where we have “blocks” of credits, each representing a purchase with an expiration date. For simplicity, we’ll also assume that the “overage rate” or the on-demand rate is the same across all purchases for a single customer.

This is an important decision point in terms of modeling: we might instead implement expiration by simply scheduling an async worker in the future to “reduce” credits when they expire on a certain date. Although this might allow us to keep the concept of a single balance which just fluctuates over time, it would be hard to keep logically consistent, not to mention the race conditions with scheduled work. With a single balance, it’s hard to differentiate which purchases have been consumed, and even trickier to properly deduct credits on expiry. Let’s proceed, then, with a stateless data model and push the handling of expiry to the query side:


Customer:
  id: str
  name: str
  ...
  credits_overage_rate: Decimal

CreditBlock:
  id: str
  customer_id: ForeignKey[Customer.id]
  remaining_balance: Decimal
  expiration_time: timestamp

BalanceUpdateAuditRecord:
  id: str
  action_time: timestamp
  credit_balance_id: ForeignKey[CreditBlock.id]
  previous_balance: Decimal
  new_balance: Decimal

Note that with our refactor, we’ve made it harder to understand the customer’s remaining balance — we now need to aggregate the remaining balance across all unexpired credit blocks which may get expensive over time. Perhaps this is something we’ll want to denormalize and keep updated in a single place later on, but we can optimize performance at a later point.

Before we talk about the revised flow as a whole, there are two other challenging aspects to highlight:

  1. Expiring credits in the middle of a billing period isn’t straightforward. Suppose, for example, that we want promotional credits to expire after a 45 day trial — it’s important that we only allow our customers to use credits for the portion of the month that they were actually valid and unexpired. This means we’ll need to construct our usage queries against Redshift to understand the boundaries of credit expiration.
  2. Deducting credits across multiple blocks at invoicing time requires some more sophisticated business logic. In order to be friendly to our customers, we’ll pick the soonest expiring set of credits to deduct from first.

The flow now looks like this, skipping parts that haven’t changed:

  1. Query the relational database for all CreditBlock records where the expiration_time is within the relevant billing period bounds. This will allow us to fetch all blocks that could have been utilized by our customer.
  2. For each credit block that expires during the billing period, construct a usage query to Redshift where the end_time is the block’s expiration time, and the start_time of the query is either the beginning of the period or the previous block’s expiration. 
  3. Using our new deduction algorithm, deduct from the soonest expiring block first. We’ll want to be careful that we allow deducting for usage that happened while this block was active (which could overlap with a previously expiring block) but not for usage that was incurred after a given block expired.
  4. If all eligible credit blocks are depleted, apply the contracted overage rate to the remaining credits and add this to the Stripe invoice.
  5. Create a BalanceUpdateRecord for each block which was deducted from or expired.

To illustrate this, suppose we’re invoicing for the month of April and we have two blocks: one that expires on April 10 and one that expires on April 20.

In this scenario, we should charge a total of 15 units of overage.

  1. April 1 - April 10: 15 images are covered by Block A (completely depleted) and Block B (20 credits remaining).
  2. April 10 - April 20: 10 images are covered by Block B, and the remaining credits expire.
  3. April 20 - April 30: 15 units of overage are charged.

Although this is more complicated and less intuitive, it lets us calculate an accurate total accounting for multiple purchases. We should note that this is still a very simple scenario: if our sales team extends expirations (e.g. on renewal, having credits ‘roll over’) or we need to support credits in the future (e.g. a contract where the credits are staged throughout the year), that would be harder to model. Setting those concerns aside, let’s move to another basic priority: showing our enterprise customers how much balance they have left.

Providing mid-period visibility

In order to provide visibility into a customer’s remaining balance, we might query for the remaining balance across all their credit blocks which haven’t expired in previous months. However, this doesn’t reflect an up-to-date notion of their current balance because it doesn’t account for usage in the month so far. As we just noted above, doing that in real time is not so easy! It requires running multiple queries against Redshift and effectively constructing a timeline of usage which may or may not be covered by a credit block.

It’s important for accuracy that the way we indicate a customer’s current balance is consistent with how we invoice for it: we can’t afford to approximate it with a cheaper method, or simply show them a stale balance that leaves our customers confused or erodes trust. We’ll choose to create an asynchronous system that is constantly calculating the updated balance for a customer via the above method and updating it in our relational database. Note that this process shouldn’t actually change the remaining balance on any block since it’s just a preview of the balance and might change, especially with delays in our reporting pipeline: only the invoicing process should mutate credit blocks.

Recognizing revenue for prepaid balance

Although it’s out of the scope of our role on the engineering team, our finance team needs to be able to track how a customer’s balance is changing for accounting reasons. 

Here’s the quick primer we get from them on revenue recognition — three simple rules:

  1. When a customer purchases credits, the revenue is considered deferred revenue until it’s used or it expires. The revenue is calculated by the number of credits multiplied by the price per credit, also known as its cost basis.
  2. When a customer utilizes credits with image processing calls, PixelMate needs to recognize that revenue based on the original cost basis.
  3. When credits expire, PixelMate needs to recognize that revenue based on the original cost basis.

Unfortunately, this isn’t easy to track with existing tools like NetSuite because these tools operate on invoices; in this process, only the initial purchase will have an invoice but the utilization and expiry doesn’t necessarily produce one. Although our finance team is currently handling this logic completely outside our product in Excel spreadsheets, we can give them the inputs they need to run this process more effectively. We’ll need to add the concept of a cost basis to our data model:


CreditBlock:
  id: str
  customer_id: ForeignKey[Customer.id]
  remaining_balance: Decimal
  per_credit_cost_basis: Decimal
  expiration_time: timestamp

Using our update records, every time an invoicing period closes, we’ll now be able to generate a report that has the following:

  1. Credit blocks purchased, alongside their cost basis. This revenue will be added to the deferred account.
  2. Credit blocks expired or depleted, separated by their cost basis. This revenue will be added to the recognized account.
  3. Revenue collected as ‘overage’, since there was no eligible credit block. This revenue will also be recognized as it happens.

Note that this only works if each subscription is billed on the first of each month, aligning with the accounting period – if not, we’ll need to make sure we can provide this data even before our invoicing process runs!

The bottom line

Our internal system continues to grow in complexity: unfortunately, we’re building up more business logic in our internal tooling rather than relying on Stripe Billing to handle the heavy lifting for us. With these additions, we have to maintain a separate pathway to calculate prepaid balances. Additionally upgrading self-serve customers to an enterprise plan would come with its own set of both engineering and accounting challenges.

Orb’s prepaid ledger is built to handle these complexities. Orb automatically keeps track of each deduction to your balance in real time and handles late event reporting to provide you the most flexibility in your process. For finance teams, Orb’s reporting is seamlessly integrated with the ledger to help you understand how to recognize revenue each day.

Having built up this system, customers are happy but our account executives are wanting more visibility in Salesforce to drive renewal and upsell conversations. We’ll cover that in a future post!

posted:
July 26, 2023
Category:
Guide

Let's talk.

Thank you! We'll be in touch shortly.
Oops! Something went wrong while submitting the form.