Welcome to the first part on a series of posts I'll be writing detailing how to design your own store and sell your products - a part of programming I find both equally fascinating and difficult, and packed full of interesting challenges and design decisions that are not immediately obvious. This first post will discuss pricing your SaaS and handling currencies - both which require careful thought.
I've always taken great joy in writing about our business. Over 10 years ago when me and Ashley started Construct we always took the opinion that writing openly and transparently was the best long term approach. As the business has grown you find naturally that writing about certain topics such as our pricing models is harder to press the publish button on, there's slightly more at stake and the volumes of people who may take up issue with some points is larger - hopefully this post helps me get back into writing and I can stay true to my younger and more naïve self!
20 years ago I wrote my first checkout for my SMS website when sending SMS messages abroad was expensive. Since then, I've written a checkout process for Construct 2 (pay once), then for Construct 3 (SaaS) followed by a storefront for our Asset Store. Moving your flagship product from a pay once model to a SaaS model has some interesting challenges and is a vulnerable period of time for your business.
We're about to launch Construct Animate which uses the Asset Store billing system (as it's far better designed), with a longer term plan to move the Construct 3 billing into the same system. This has the additional benefit of the potential for third party SaaS products and services to be hosted in our asset store.
There have been plenty of pitfalls in design along the way and even more lessons learnt. I do feel now after all the iterations we've landed on a pretty good design.
Why Write Your Own Store?
We released Construct 3 back in 2017 as a SaaS product. We had a need to:
- Offer different pricing in different regions
- Offer different billing cycles into different regions
- Have different customer types (Personal, Business & Education) all with their own pricing and varying billing cycles
- Integrate our sales into our accounting system, the main pain point being VAT handling
- Like all our web services, we wanted seamless integration with our user account system - as little friction between third party services as possible
Back in 2016 when I first started developing the checkout process for Construct 3 there wasn't an obvious third party choice that could cover all these points, especially with regards to VAT (which probably deserves its own post).
Additionally, a lot of third party services would skim up to 5% off the top of your revenues which is not ideal and it does add up to significant sums over time. Third party services also carry some risks - for example you're at the mercy of any price increases they impose or they may close shop at short notice. Some third party services also retain the customer's billing agreements in their system making moving or changing in the future a process fraught with really difficult problems.
Our current asset store, allowing sellers to upload their assets and sell them through our system
With regards to web development, writing store systems has probably been the most interesting and challenging part of my job which I thoroughly enjoy doing - perhaps in the past we could have saved a lot of time and teething issues however I have no regrets being where we are today.
Why Not To Write Your Own Store
- If you screw up, there's potential to screw up badly. Unlike a lot of web development once you have the ability to charge customers programmatically you enter dangerous territory where your mistakes can have a large negative impact on people's lives
- It's really hard
- It takes a long time to get right
I've never written a store system that needs to track inventory or ship physical items - this adds another layer of complexity. For these sorts of stores to get up and running quickly a third party system may be the best choice.
Let's Start with Pricing
How much to charge your customers seems like a reasonable first problem to tackle. There's been plenty of good articles written on pricing your product yet I feel a lot miss the mark with describing some of the design decisions.
If you wish to sell your product globally, the easiest solution would be to simply sell it for $10 USD per month everywhere. Simple easy to manage pricing. This has some negative effects though:
- Someone in a country where USD is not their native currency may incur foreign currency fees on each transaction imposed by their bank which is often unexpected - some banks often communicate these fees so badly customers believe you have overcharged them
- Someone in a country with a volatile currency will not be able to accurately predict how much you're going to charge them next month
- $10 USD a month might be affordable in USA, but not so much in other countries
- You want to present customers familiar pricing - it's going to be easier for them to evaluate the value proposition looking at prices in their native currency
The last thing you want is customers hesitating to purchase because they don't know exactly how much it's going to cost them, or how much it may cost them in the future. You also likely do not want to price yourself out of markets.
So what's the right strategy to price?
One solution you might consider is charging $10 USD per month, and then offering the country's native currency based on the USD price. I strongly beleive this is the wrong solution:
- The native currency price will fluctuate as the exchange rate changes
- This will lead to situations where customers are incentivised to cancel their plans and resubscribe if the advertised price decreases. Ultimately if your customers are proactive here they will continually evolve onto cheaper and cheaper plans (and make calculating your churn rates and other metrics more difficult)
- On the other end, if the currency moves against you customers will be on cheaper than advertised plans (you may start to incentivise customers to sell access to their accounts in extreme circumstances)
- It's hard to communicate to customers what your pricing is if it's changing
I feel like the best strategy for defining the price of your product is to fix it to each country's native currency. Today, $10 USD is around £8.32 GBP so we'll sell our plans in the USA for $10 and £8.39 in the UK. There are nearly 200 countries in the world, so we're starting to commit to some time consuming data entry here!
Fixing your prices in the country's native pricing puts the fluctuations in currency markets on your business and not the customer. This sounds bad, but I actually think this is the correct approach as it reduces friction for customers and you should be pricing your product to what the market can bear and not fluctuating it to reduce volatility in your revenues when the fluctuations can create unpredictable incentives or disincentives on the customer.
The USD/BRL exchange rate has changed significantly in a few years. If you set your BRL pricing to the USD exchange rate you could be pricing yourself out of the Brazilian market
We have found even when the native currency is offered initially to customers in their country, they do often have the need to pay in USD. Often this is because they are a business and they have a USD card specifically for foreign expenses. For this reason, we always show the native currency by default but have USD as a second option for every country which fluctuates against the native rate. For customers purchasing in USD from a different country this does have the downsides mentioned above, but we find most volume in each country is processed in the native currency.
So what's the right price?
We mentioned previously that $10 USD in USA might be affordable, but less so in other countries. So how do we determine the correct price?
You have to start somewhere, and the pricing for the country you live in is probably the best place to start and you will have some sense of what is reasonable. From here, you can likely use similar prices for countries with similar economic conditions. There's lots of different approaches from here. One we've found useful as draft guidance is to categorise countries into average salaries or similar metrics and then discount or increase the pricing based on this categorisation.
Every now and then we review our pricing. What we've found is useful to dig into our analytics for our website and over a given period list how many visitors we've had on our website from those countries. Then, we divide the total number of paying users by this number, and order in descending order. At the top, we'll have our best performing countries (which you could argue could bear a price increase) and at the bottom we have the worst performing countries which strongly suggests a price decrease is needed.
Performing this exercise, we managed to stimulate a country with a high number of visitors but almost zero sales into buying our products. Before doing this however, it's important to test purchases and your implementation to ensure there isn't a technical issue stopping purchases - you can't rely on customers to email you when a problem like this exists.
We find it beneficial to constantly review and iterate our pricing - however you should take care especially if your product is SaaS. Do this too frequently and too violently and customers might get worried, too infrequently and you're likely missing value. You have to also consider that boosting revenue at any cost (for example offering your product for $1 a year in a country with zero sales) may actually negatively impact your business as these customers require support and may increase costs in other areas.
Others have written better on this subject than I ever could, but it's easy to undersell yourself especially if you're technical founders not from a salesy background. At first (and we didn't do this with our products) it's probably better to price yourself too high rather than too low. If you're too highly priced it's going to be relatively easy to drop your price - but keep in mind it's probably appropriate to surprise your current customer base with partial refunds, as these will be some of your most valuable advocates going forwards and you do not want to lose them. If you sell for an extended period with too low prices also you'll find that some of your customer base (often the noisier part) may have unrealistic expectations going forwards - this can be tremendously damaging for your business.
Don't Sell to Some Countries
Very important to note is that your country may have trade embargos on other countries meaning it's illegal to sell to these countries. Make sure you take all the steps you're able to prevent purchases from these regions.
If you're looking to raise money from investors in the USA, it may also be strategic to avoid selling to countries the US has embargoed even if your country permits sales to these countries.
Fraud
Now we're charging $10 USD in USA, and perhaps $5 USD as a backup currency in another country, we're obviously opening ourselves up to customers changing their region and purchasing cheaper plans.
There's a few ways to tackle this, but it's important to note this is probably not as big an issue as you think it might be. We rarely see customers doing this, as most customers are honest and progress through your checkout without trying to gain advantage especially if you're a smaller business who work hard to maintain a good relationship with customers and the wider community.
When a customer buys from your website, you'll likely have three points of data:
- The customer's geo location based on their IP (we use MaxMind's GeoIP service)
- The customer's entered invoice/billing address
- Post payment, most payment provides will feedback information about what country the customer's payment method is registered to
In the first instance, it's important to make sure you're showing the correct pricing to a customer based on their country!
Secondly, at checkout when the customer starts to enter their billing information and the country they enter differs from the country the pricing is set to, you should redirect them or prevent the sale.
Post payment, it's easy to review the customer's payment method country and catch anyone trying to circumvent your pricing.
Perceived Unfairness
Observant and curious customers on your site may feel unfairly discriminated against if they see geo pricing. There isn't a one size fits all way to respond to these queries but we've found being upfront and honest about why you've taken this strategy works well.
Currencies are Hard
Now we've got our pricing set for each country and we're happy with it for now, we can start thinking about implementation and design. Even if you're reading this you're likely aware - you want to always store amounts charged as int64
. This is the easiest data type to work with with regards to currency, I'd go as far to say the correct one and any other choice is wrong. (Come at me!)
Subunits
It would be a mistake to assume all currencies around the world have 100 cents to the dollar. For example, the Japanese Yen (JPY) has no subunits (99.99 JPY is not a valid amount). More confusingly, the Taiwan dollar (TWD) has 10 subunits. If you wanted to charge in Bitcoin, you might design to charge on Satoshis of which there are 100,000,000 in 1 Bitcoin.
Once you've got your head around subunits, additional difficulties are introduced by payment processors. HUF (Hungarian Forint) is listed as having 100 subunits on Stripe, yet on Paypal it's a zero subunit currency.
We've found the simplest approach is to store all currencies in the database with their subunits which you'll aim to never update, then when sending an amount in a currency to a payment processor you convert it, and when receiving an amount from a payment processor you convert it again.
function convertAmountFromPaypal(Currency currency, long amountCents);
function convertAmountToPaypal(Currency currency, long amountCents);
function convertAmountFromStripe(Currency currency, long amountCents);
function convertAmountToStripe(Currency currency, long amountCents);
If payment processors change the way they treat subunits in a particular currency (it happens) then modifying your methods is the easiest way to maintain correctness. If you ever convert a price from one currency to another or you display a price, you must also ensure that subunits aren't shown for currencies with no subunits.
Formatting Currencies
When showing a price to a customer, it's imperative that you format the currency correctly. Different cultures have different separator characters for thousands and subunit separators. Mixing these up or naively using commas and decimals can lead to a lot of confusion and even unexpected costs or savings from buyers!
As an example the Afghan Afghani (AFN) uses dots for thousands separators, and commas for subunit separators. So $100 USD
in AFN should be shown to customers as 8.846,71
, and not 8,846.71
. For example, $1 USD
in AFN is 88.40
, if we round this to 89.99 AFN
and show the price as 89.99
and not 89,99
as we should the price may appear as confusing and nonsensical.
Fortunately in ASP.net some helpful methods exist to help get the correct separators. For AFN, we set the formatting culture to ps-AF
, and then call the following methods to get the separators:
var subunitSeparator = new CultureInfo("ps-AF").NumberFormat.CurrencyDecimalSeparator;
var thousandsSeparator = new CultureInfo("ps-AF").NumberFormat.CurrencyGroupSeparator;
When showing a price to a customer, we always pass it through a formatting method:
function formatPrice(Currency currency, long amountCents, bool hideSubunitsIfZero);
These sorts of helper methods are important to use not only to ensure the price is displayed correctly, but are also be helpful when you want to slightly tweak how prices are displayed in different situations (e.g. a price to purchase versus an amount shown on an invoice)
Exchange Rates
We use OpenExchangeRates to update all exchange rates for currencies on regular interval. Again, we can't rely on the fact that your exchange rate provider will treat all currency subunits consistently with your application so it's advisable to pass all values through a helper function which even if you don't need to use immediately may be useful in the future.
When recording exchange rates, it's useful to have a base currency which you're recording rates based on such as USD. Any exchange rate you record is the exchange rate against the base currency you choose. You'll most commonly need to get a currency's exchange rate at the current time, so when a new exchange rate is recorded you should store the latest rate and the time is was recorded in the currency object.
Exchange rates are stored in our database in the following format:
Date |
DateTime |
The date time this exchange rate was recorded |
CurrencyID |
int |
The ID of the currency the exchange rate is for |
Rate |
Decimal |
The exchange rate recorded |
We then need two helper methods:
GetExchangeRate(Currency forCurrency, DateTime atDate)
ConvertAmount(Currency fromCurrency, Currency toCurrency, long amountCents)
Get Exchange Rate
When getting the exchange rate for a currency, first we can check if the currency's latest exchange rate stored in the currency object was recorded in the past. If this is the case we can simply return this exchange rate.
If you're requesting a historical exchange rate (which you may find yourself doing more than you expect), the easiest way is to query the database and ensure you handle cases where no rates are returned (for example the date is prior to any recorded rates).
Although this works just fine, you generally want to cache as much as possible and avoid repeated queries to your database. Our solution is to cache sections of the exchange rates into time periods. For example, if you record exchange rates hourly and you want to get the exchange rate on 12th September 2021 at 12:45pm, you'd create a cached object that holds the ordered exchange rates for that currency on the 12th September 2021. Once you have this object, you can binary search the ~24 values to find the one which is for the specified time you've requested it at - don't assume cached days have any values though due to outages in the exchange rate service. In such cases you'll want to iterate through previous days but again be careful this doesn't run indefinitely in cases where you're trying to get the exchange rate on a date before any were recorded.
Convert Amount
All exchange rates are recorded based on our base currency which is USD. When you need to convert an amount from GBP to AUD, you need to convert the currency you're exchanging from to the base currency, then convert the base currency to the target currency. Doing this round trip allows us to avoid storing exchange rates for all pairs of currencies!
public static long ConvertAmount(Models.Currency fromCurrency, Models.Currency toCurrency, long amountCents, DateTime? at = null)
{
if (amountCents == 0) return 0;
decimal amount = amountCents;
var baseCurrency = Currency.Functions.GetDefaultBaseCurrency();
// Convert from currency to base currency
if (!fromCurrency.IsDefaultBaseCurrency)
{
var fromRate = fromCurrency.LastExchangeRate;
if (at.HasValue)
{
fromRate = GetBaseExchangeRateAtDate(fromCurrency, at.Value);
}
amount /= fromRate;
if (fromCurrency.SubUnits != baseCurrency.SubUnits)
{
var fromSubUnits = Math.Max(1, fromCurrency.SubUnits);
var toSubUnits = Math.Max(1, toCurrency.SubUnits);
amount /= fromSubUnits;
amount *= toSubUnits;
}
}
// Amount cents is now in base currency, convert to target currency
if (!toCurrency.IsDefaultBaseCurrency)
{
var toRate = toCurrency.LastExchangeRate;
if (at.HasValue)
{
toRate = GetBaseExchangeRateAtDate(toCurrency, at.Value);
}
amount *= toRate;
if (baseCurrency.SubUnits != toCurrency.SubUnits)
{
var fromSubUnits = Math.Max(1, baseCurrency.SubUnits);
var toSubUnits = Math.Max(1, toCurrency.SubUnits);
amount /= fromSubUnits;
amount *= toSubUnits;
}
}
return Convert.ToInt64(Math.Ceiling(amount));
}
Rounding Pricing
I'm not exactly sure what the current school of thought is for improving revenue and conversion rates with prices (is $99 better than $99.99?) but you should incorporate some level of rounding when setting a price.
$15 USD in LBP (Lebanese pound) is 1,487,590.00
- not an especially easy price for anyone in Lebanon to quickly come to a judgement as to if it offers good value to them. We'd aim to show this price as 1,499,000.00
, and remove the redundant subunits to show the cleanest price possible as 1,499,000
.
As explained earlier, for most currencies we set the prices explicitly but when calculating the USD backup currency price we may want to apply rounding. This has two benefits: the price looks a lot nicer, and the price won't keep changing until the exchange rate passes the next rounding threshold.
For this, you'd need to create a rounding function. We have a need for a general purpose rounding function because when store sellers in our asset store sell their goods we can't rely on them setting prices for every country explicitly. This is not as easy as you might assume, because for currencies such as USD where the value you wish to round is $9.83 it makes sense to go to $9.99 because the subunits are meaningful, but for prices such as 1,487,590.00 LBP you wouldn't want to round it to 1,499,999.99 as beyond a certain point the subunits become largely redundant in terms of value to your business and without having any particular reasoning for my decision it just looks plain ugly. With this in mind, you'd want to create a rounding function that zeros out subunits from a certain point which you could possible calculate based on some minimum value threshold in your businesses country (EG if a subunit at a point in time is worth less than $0.10 then you can zero them). This can and should be done programmatically if implemented carefully! Writing a general purpose rounding function is deceptively difficult to write, but fun to try and figure out.
Things get tricky when a price given to a customer is rounded from another currency, and there is a need to check the price they subscribed at was correct at a given time (for example historically). With this in mind, it's important to be conscious of the fact that any change to your rounding method may create incorrect calculations. You can either fix this by designing away the need to perform this action, or by carefully managing/versioning your rounding function changes.
Prepare For Change
It's important to ensure your design allows for currencies being phased out. For example on December 31st 2022 the Croatian Kuna was phased out and replaced by the Euro. As much as I enjoy reading about finance and economics these things can still often come as a surprise, thankfully Stripe seem to be on-top of such things and communicate these changes within a reasonable amount of time! Such changes can pose some difficult changes to implement.
When dealing with changes such as this, you can either communicate to your customers about the upcoming change in billing (and all the work your end that precedes that), or if you think the impact on your business is low you can allow the plans to lapse and count on the fact that the customers likely will re-subscribe with new plans. It's really hard to plan for events such as this, and it's sometimes OK to do nothing - building your own store is full of edge cases that it's often just not economical to plan for.
Thanks for reading!
I hope this post was interesting and I hope to continue writing some more posts in the future about designing your own online store.