Context – RevenueCat and ChartMogul integration

Recently I’ve been working on a project that was about implementing upgrades capabilities to the mobile app we develop. My part specifically was focused on subscription analytics integration. The integration connects two third-party tools: RevenueCat on the one end, which abstracts in-app subscription purchases, and ChartMogul on the other end, which serves as a lens for business people to look into the financial performance of our B2C segment.

One challenge that immediately hit me was that, because we tie together two very specific tools it is not so simple to find a step-by-step guide on how to accomplish it. I started my research trying to figure out how to track upgrades with ChartMogul. It turned out, that it was not that easy to find an answer.

Eventually, we’ve found a solution that seems to be working for us. Because I spent so much time on it anyway, I decided to describe it. Perhaps someone needs to solve a similar problem and my work can serve as an inspiration (even if it may be incorrect).

How do subscription upgrades work?

Let’s start by giving some information first. What is a subscription? It’s a period when a user benefits from certain features of the app it uses. What is a subscription upgrade? It’s when a user switches to a more expensive plan that offers more features. Don’t confuse it with cross-grades when a user switches the billing cycle but stays with the same product.

How do in-app stores handle subscription upgrades? Here’s the fun part. AppStore and Google play handle them differently.

Subscription upgrades with GooglePlay

Let’s start with answering how upgrades are handled with the Google Play store. When reading Google Play’s docs we can see, that there are multiple ways how the upgrades can be done. However, according to the current state of RevenueCat, only the IMMEDIATE_WITH_TIME_PRORATION mode is supported. Therefore, let’s try to understand better how it works.

Immediate with time propration – how does it work?

IMMEDIATE_WITH_TIME_PRORATION proration mode – the documentation describes this as follows: The subscription is upgraded or downgraded immediately. Any time remaining is adjusted based on the price difference, and credited toward the new subscription by pushing forward the next billing date. This is the default behavior.

How does it look in practice? Let’s look at the picture:

We have a user who bought a subscription (product 1) on date 1 for the price of X. On date 2, they upgraded to product 2. They are not charged any money yet. The duration – let’s call it a transition period from now on – is calculated this way: how much of product 1 is not used yet (let’s call it the remaining price)? How much time would you have for that value for the price of product 2? When the transition period is done (date 3), the RENEWAL event happens, and the user pays a full price Y for product 2. At date 4, nothing happens, because the user no longer uses product 1.

RevenueCat example

We now have a high-level understanding of the IMMEDIATE_WITH_TIME_PRORATION mode. Now, let’s look at the concrete examples of events that arrived in our system (of course, all information that’s specific to our system and its users is removed).

On date 1 – INITIAL PURCHASE arrives:

{
  "api_version": "1.0",
  "event": {
    "aliases": [
      "user_1"
    ],
    "app_id": "app_id",
    "app_user_id": "user_1",
    "commission_percentage": 0.1261,
    "country_code": "XX",
    "currency": "EUR",
    "entitlement_id": "entitlement_id",
    "entitlement_ids": [
      "entitlement_id"
    ],
    "environment": "PRODUCTION",
    "event_timestamp_ms": 1677150677946,
    "expiration_at_ms": 1679576971854,
    "id": "EVENT000-ID00-0000-0000-000000000001",
    "is_family_share": false,
    "offer_code": null,
    "original_app_user_id": "user_1",
    "original_transaction_id": "GPA.0000-0000-0000-00000",
    "period_type": "NORMAL",
    "presented_offering_id": null,
    "price": 17.489,
    "price_in_purchased_currency": 16.49,
    "product_id": "product_1",
    "purchased_at_ms": 1677150660098,
    "store": "PLAY_STORE",
    "subscriber_attributes": {},
    "takehome_percentage": 0.85,
    "tax_percentage": 0.1597,
    "transaction_id": "GPA.0000-0000-0000-00000",
    "type": "INITIAL_PURCHASE"
  }
}

On date 2, when the user upgrades, another INITIAL_PURCHASE arrives:

{
  "api_version": "1.0",
  "event": {
    "aliases": [
      "user_1"
    ],
    "app_id": "app_id",
    "app_user_id": "user_1",
    "commission_percentage": 0.1261,
    "country_code": "XX",
    "currency": "EUR",
    "entitlement_id": null,
    "entitlement_ids": [
      "entitlement_id"
    ],
    "environment": "PRODUCTION",
    "event_timestamp_ms": 1677772904518,
    "expiration_at_ms": 1679943925996,
    "id": "EVENT000-ID00-0000-0000-000000000002",
    "is_family_share": false,
    "offer_code": null,
    "original_app_user_id": "user_1",
    "original_transaction_id": "GPA.0000-0000-0000-11111",
    "period_type": "NORMAL",
    "presented_offering_id": null,
    "price": 0,
    "price_in_purchased_currency": 0,
    "product_id": "product_2",
    "purchased_at_ms": 1677772848921,
    "store": "PLAY_STORE",
    "subscriber_attributes": {},
    "takehome_percentage": 0.85,
    "tax_percentage": 0.1597,
    "transaction_id": "GPA.0000-0000-0000-11111",
    "type": "INITIAL_PURCHASE"
  }
}

As you may see, the price is 0, and the entitlement_id as well as the product_id has changed. There is also an additional PRODUCT_CHANGE event. It also doesn’t carry any pricing information, but it has two fields: product_id with the old product id and the new_product_id.

How to track GooglePlay upgrades with ChartMogul?

We had one specific requirement. We wanted to show MRR (Monthly recurring revenue) movements in ChartMogul, immediately after the user has upgraded. The transition period may take a long time, and we wanted to see if our product-related activities bring value, when it comes to MRR. To achieve that, we needed to know the price of the new product.

But there was a limitation. RevenueCat doesn’t provide any API to fetch that information. We also didn’t have it stored on our side. Developing such a system for storing product prices brings a whole set of new challenges (price tests, history of price changes in case we needed to reimport the data, etc.), so that was not an option. So we became wondering: Is there a way to calculate the price of the new product, knowing the transition period duration, and the old product’s price?

Price estimation

We thought about using the proportion because it seems natural given the description of the mode says that the transition period is equivalent to the unused time of the old period, but with a different unit value. Ultimately we came up with the formula:

new_price = (new_period_duration * old_price_remaining) / transition_period_duration
  • new_period_duration is the expected duration of the new product. So if the new subscription has a 6-months billing cycle, the value is 6 months (of course in the common unit). This doesn’t come with the event. We would have to somehow fetch it from our data source based on the entitlement_id and product_id combination.
  • transition_period_duration – this is something we can get from the second event: expiration_at_ms - purchased_at_ms
  • old_price_remaining to calculate that we need another formula:
old_price_remaining = old_price - (old_price * time_used) / old_period_duration
  • old_price – is the full price of the previous period – the price_in_purchased_currency field
  • time_used – is the difference between date 2 and date 1. How much time has passed since the beginning of the subscription, until the user upgraded?
  • old_period_duration – the total duration of the previous subscription if it wasn’t upgraded. It can be calculated from the first event as expiration_at_ms - purchased_at_ms

Let’s make an example with real values. To calculate new_period_duration we will use date-fns

const _ = require('date-fns');
const date3 = new Date(1679943925996); // expiration_at_ms from the second event
const date5 = _.add(date3, { months: 6 }); // 2023-09-27T19:05:25.996Z
new_period_duration = _.differenceInMilliseconds(date5, date3); // 15897600000

and now put the values into the formula

old_price = 1649 // in cents
time_used = 622188823
old_period_duration = 2426311756
old_price_remaining = 1649 - (1649 * 622188823) / 2426311756 = 1226 // rounded
transition_period_duration = 2171077075
new_price = 15897600000 * 1226 / 2171077075 = 8977 // rounded, in cents

We know that the price should be 8999 (€89.99), so this is an acceptable difference for us (€0.21). It comes from subtle differences in timestamps. If convert the timestamps into a human-readable format you can see that the old_period_duration is not exactly one month, and we need to make some assumptions to calculate  new_period_duration:

date1 = new Date(1677150660098).toISOString(); // 2023-02-23T11:11:00.098Z
date4 = new Date(1679576971854).toISOString(); // 2023-03-23T13:09:31.854Z

ChartMogul invoices

Having these values, we can now call the ChartMogul API to create an invoice.
The invoice should have two line items:

  • first line item – is the old period that has ended
    • amount_in_cents – is the old_price_remaining value we calculated before, but negative e.g. -1226
    • prorated set to true
    • service_period_start and service_period_end – stay unchanged, they should reflect the initial period – i.e. (date 1, date 4)
    • cancelled_at – set to date 2. We use original_transaction_id as the subscription identifier in ChartMogul. Because of that, we have to cancel the current subscription, otherwise, it would treat it as two subscriptions running in parallel.
      • it won’t affect the churn rate, because it happens within the same day – source
  • second line item – is the transition period.
    • service_period_start and service_period_end is the time range of the transition period – i.e. date 2 and date 3.
    • prorated set to false.
    • amount_in_cents set to the estimated price we just calculated

Later, when the actual RENEWAL event happens (date 3), we can delete the invoice (ChartMogul doesn’t support editing invoices. You have to delete and recreate it), and create the correct one (we now have the correct price).

Subscription upgrades with AppStore

For subscriptions bought on iOS, the logic is simpler. Reading in the docs we can see that users are “immediately upgraded and receive a refund of the prorated amount of their original subscription”.


On date 1, the user buys product 1. On date 2 they upgrade to product 2 for the price of Y. They are charged a full price and receive access immediately. After some time (date 3) they receive a refund for the time they haven’t used product 1. On date 4, nothing happens, because the user no longer uses product 1.

RevenueCat example

Here are examples of the events that reach the RevenueCat Webhook endpoint.
On date 1 we receive an RENEWAL event. Normally it would be a INITIAL_PURCHASE but in this case, the user had a free 7-day trial before, and now converts to a full subscription.

{
  "api_version": "1.0",
  "event": {
    "aliases": [
      "user_2"
    ],
    "app_id": "app_id",
    "app_user_id": "user_2",
    "commission_percentage": 0.15,
    "country_code": "XX",
    "currency": "EUR",
    "entitlement_id": null,
    "entitlement_ids": [
      "entitlement_1"
    ],
    "environment": "PRODUCTION",
    "event_timestamp_ms": 1665817701639,
    "expiration_at_ms": 1697382446000,
    "id": "EVENT000-ID00-0000-0000-100000000000",
    "is_family_share": false,
    "is_trial_conversion": true,
    "offer_code": null,
    "original_app_user_id": "user_2",
    "original_transaction_id": "350000000000000",
    "period_type": "NORMAL",
    "presented_offering_id": null,
    "price": 87.484,
    "price_in_purchased_currency": 89.99,
    "product_id": "product_1",
    "purchased_at_ms": 1665846446000,
    "store": "APP_STORE",
    "subscriber_attributes": {},
    "takehome_percentage": 0.85,
    "tax_percentage": 0.1357,
    "transaction_id": "350000000000001",
    "type": "RENEWAL"
  }
}

You may notice that entitlement_id is null. You shouldn’t use that field anyway, because it’s deprecated. entitlement_ids has a correct value.

On date 2, the user upgrades to product 2, and we receive another RENEWAL event.

{
  "api_version": "1.0",
  "event": {
    "aliases": [
      "user_2"
    ],
    "app_id": "app_id",
    "app_user_id": "user_2",
    "commission_percentage": 0.15,
    "country_code": "XX",
    "currency": "EUR",
    "entitlement_id": null,
    "entitlement_ids": [
      "entitlement_2"
    ],
    "environment": "PRODUCTION",
    "event_timestamp_ms": 1669567713849,
    "expiration_at_ms": 1701103663000,
    "id": "EVENT000-ID00-0000-0000-200000000000",
    "is_family_share": false,
    "is_trial_conversion": false,
    "offer_code": null,
    "original_app_user_id": "user_2",
    "original_transaction_id": "350000000000000",
    "period_type": "NORMAL",
    "presented_offering_id": null,
    "price": 156.207,
    "price_in_purchased_currency": 149.99,
    "product_id": "product_2",
    "purchased_at_ms": 1669567663000,
    "store": "APP_STORE",
    "subscriber_attributes": {},
    "takehome_percentage": 0.85,
    "tax_percentage": 0.1357,
    "transaction_id": "350000000000001",
    "type": "RENEWAL"
  }
}

On date 3, the refund would happen, however, that operation is not visible in RevenueCat.

How to track AppStore upgrades with ChartMogul?

Because we can’t see the refunded value, we have to calculate it on our own. From the GooglePlay example, we already have the formula for calculating the remaining price. This article also proves that the formula is correct.

Let’s bring it up again:

old_price_remaining = old_price - (old_price * time_used) / old_period_duration

So putting real values from the events:

old_price = 8999
time_used = 1669567663000 - 1665846446000 = 3721217000
old_period_duration = 1697382446000 - 1665846446000 = 31536000000
old_price_remaining = 7937 // rounded

Given the value, we can now send an invoice to ChartMogul with two line items

  • first line item – is the old period that has ended
    • amount_in_cents – is the old_price_remaining value we just calculated before, but negative e.g. -7937
    • prorated set to true
    • service_period_start and service_period_end – stay unchanged, they should reflect the initial period – i.e. (date 1, date 4)
    • in this case, we don’t have to send cancelled_at because the original_transaction_id that we use as a subscription id stays the same
  • second line item – is the new product
    • service_period_start and service_period_end – are simply purchased_at_ms and expiration_at_ms from the second event
    • prorated set to false.
    • amount_in_cents is the new price.

As I mentioned earlier, I’m not sure if my solution is correct. If you see something incorrect in my reasoning, please share your feedback with me. You can find ways to contact me here.

Author

I'm a software engineer with 9 years of experience. I highly value team work and focus a lot on knowledge sharing aspects within teams. I also support companies with technical interview process. On top of that I read psychological books in my spare time and find other people fascinating.