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 theentitlement_id
andproduct_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 – theprice_in_purchased_currency
fieldtime_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 asexpiration_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 theold_price_remaining
value we calculated before, but negative e.g.-1226
prorated
set to trueservice_period_start
andservice_period_end
– stay unchanged, they should reflect the initial period – i.e. (date 1, date 4)cancelled_at
– set to date 2. We useoriginal_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
andservice_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 theold_price_remaining
value we just calculated before, but negative e.g.-7937
prorated
set to trueservice_period_start
andservice_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 theoriginal_transaction_id
that we use as a subscription id stays the same
- second line item – is the new product
service_period_start
andservice_period_end
– are simplypurchased_at_ms
andexpiration_at_ms
from the second eventprorated
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.