Open lukehutch opened 1 month ago
👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!
Let me provide some details about what I need, and what's not good about the current API. Please copy this comment across to ZenDesk, if that doesn't automatically sync with GitHub.
double price
(with separate currency ticker like 'USD'
) and as a String priceString
(with built-in currency symbol, e.g. '$24.99'
). Because I don't know how to reliably convert from ticker to currency rendering, I have to use priceString
. But then calculations become much more difficult, especially because I assume that internationalized prices use a comma rather than a period for the decimal delimiter, but only in certain locales, and different currencies have different numerical precision (either the number of significant figures or the number of decimal places).
,
or .
from priceString
, convert ,
to .
if necessary, count the number of decimal places, parse the string as a double
, then run calculations with this double (e.g. convert from the 3-monthly subscription price to the equivalent price per month, and compare the equivalent price per month to the 1-monthly subscription price, so that I can show percentage savings), convert this new double back to a string with the same number of decimal places as the original string, replace .
with ,
if the original string used ,
, then replace just the price digits in the priceString
with the new price (so that the currency symbol is preserved in the new price rendering). I'll paste my code below so you can see how ridiculous this process is right now, with no support from the framework.'$29.99/mo'
'$19.99/mo for the first 3mo (save 33%), then $29.99/mo'
'$249.99/year ($20.83/mo, save 41%)'
'$19.99/mo for the first 3mo (save 33%), then $29.99/mo'
), but I need these strings to be able to be broken down into pieces ('$19.99/mo', 'first 3mo', 'save 33%', 'then $29.99/mo'
or similar), for rendering in different ways (and some thought needs to be but into this so that it internationalizes well). For example, my paywall may have subscription tier selection cards, containing text in a column, like this:Initial savings | Save 22% | Save 41% |
---|---|---|
$17.99/mo | 3 months | 1 year |
for first 3mo | $74.99 | $249.99 |
then | ($24.99/mo) | ($20.83/mo) |
$29.99/mo |
Here is my current offering rendering code, so you can see what sort of hoops I'm jumping through to at least preserve the internationalization of the rendering of price strings (although I haven't even begun to tackle the rendering of pricing information strings as shown above):
String _introductoryPeriodDescription(
int cycles,
PeriodUnit periodUnits,
) {
// TODO: internationalize this
return switch (periodUnits) {
PeriodUnit.day => 'for ${cycles}d',
PeriodUnit.week => 'for ${cycles}w',
PeriodUnit.month => 'for ${cycles}mo',
PeriodUnit.year => 'for ${cycles}y',
PeriodUnit.unknown => '',
};
}
String _subscriptionPrice(
String priceStr,
String period,
) {
// TODO: internationalize this
final suffix = switch (period) {
'P1M' => '/mo',
'P2M' => '/2mo',
'P3M' => '/3mo',
'P6M' => '/6mo',
'P1Y' => '/yr',
_ => '',
};
return '$priceStr$suffix';
}
RegExp numberRegex = RegExp(r'[\d.,]+');
(String equivMonthlyPriceStr, int savingsPercentage) _convertPriceToMonthly(
String priceStr,
String period,
String monthlyPriceStr,
) {
final defaultResult = (_subscriptionPrice(priceStr, period), 0);
if (period == 'P1M') {
return defaultResult;
}
// Get the price string without any currency symbol
final priceWithoutCurrency = numberRegex.firstMatch(priceStr)?.group(0);
if (priceWithoutCurrency == null) {
logger.w('Could not extract price from: $priceStr');
return defaultResult;
}
// Handle case of internationalized price using a comma as a decimal separator
final usesComma =
priceWithoutCurrency.contains(',') && !priceWithoutCurrency.contains('.');
final priceNormalized = usesComma
? priceWithoutCurrency.replaceAll(',', '.')
: priceWithoutCurrency;
// Count number of decimal places in price
final decimalPlaces = priceNormalized.contains('.')
? priceNormalized.split('.').last.length
: 0;
// Parse the price string as a double
final price = double.tryParse(priceNormalized);
if (price == null) {
logger.w('Could not parse price: $priceStr');
return defaultResult;
}
// Repeat for the monthly price string
final monthlyPriceWithoutCurrency =
numberRegex.firstMatch(monthlyPriceStr)?.group(0);
if (monthlyPriceWithoutCurrency == null) {
logger.w('Could not extract monthly price from: $monthlyPriceStr');
return defaultResult;
}
final monthlyPrice =
double.tryParse(monthlyPriceWithoutCurrency.replaceAll(',', '.'));
if (monthlyPrice == null) {
logger.w('Could not parse price: $monthlyPriceStr');
return defaultResult;
}
// Determine how many months the subscription period is for
double numMonths = switch (period) {
'P1M' => 1,
'P2M' => 2,
'P3M' => 3,
'P6M' => 6,
'P1Y' => 12,
_ => 0,
};
if (numMonths == 0) {
logger.w('Unknown period: $period');
return defaultResult;
}
// Calculate the equivalent price per month
final equivPricePerMonth = price / numMonths;
// Calculate the savings percentage
final savingsPercentage =
(100 * (1 - equivPricePerMonth / monthlyPrice)).round();
// Round to the same number of decimal places as the original price, but
// first subtract 0.01, to keep monthly prices from rounding up to the
// nearest currency unit (at least for dollars/Euros or similar)
final pricePerMonthRoundedStr =
(equivPricePerMonth - 0.01).toStringAsFixed(decimalPlaces);
// Replace the period with the comma if the original price used a comma
final pricePerMonthStr = usesComma
? pricePerMonthRoundedStr.replaceAll('.', ',')
: pricePerMonthRoundedStr;
// Add the currency symbol back to the price
final priceWithCurrency =
priceStr.replaceFirst(priceWithoutCurrency, pricePerMonthStr);
// Add the monthly suffix
final priceWithCurrencyAndPerMonth =
_subscriptionPrice(priceWithCurrency, 'P1M');
return (priceWithCurrencyAndPerMonth, savingsPercentage);
}
void subscribe() async {
try {
final offerings = await Purchases.getOfferings();
final currentOffering = offerings.current;
if (currentOffering != null &&
currentOffering.availablePackages.isNotEmpty) {
// Get the monthly price string as a basis for calculating percentage
// savings for other subscription packages
final monthlyPriceStr = currentOffering.availablePackages
.where((package) => package.packageType == PackageType.monthly)
.firstOrNull
?.storeProduct
.priceString;
if (monthlyPriceStr == null) {
logger.w('No monthly subscription available');
showSnackBar('No monthly subscription available');
return;
}
for (final package in currentOffering.availablePackages) {
final product = package.storeProduct;
final category = product.productCategory;
if (category == ProductCategory.subscription) {
final packageType = package.packageType;
print('Package Type: $packageType');
var subscriptionPeriod = product.subscriptionPeriod ?? '';
final priceInfo =
_subscriptionPrice(product.priceString, subscriptionPeriod);
print('Price: $priceInfo');
if (packageType != PackageType.monthly) {
final monthlyPriceAndSavedPercentage = _convertPriceToMonthly(
product.priceString,
subscriptionPeriod,
monthlyPriceStr,
);
final monthlyPrice = monthlyPriceAndSavedPercentage.$1;
final savedPercentage = monthlyPriceAndSavedPercentage.$2;
print('Price converted to monthly: '
'$monthlyPrice (save $savedPercentage%)');
}
final introductoryPrice = product.introductoryPrice;
if (introductoryPrice != null) {
final introductoryPriceInfo = _subscriptionPrice(
product.introductoryPrice!.priceString,
product.introductoryPrice!.period);
final introductoryPricePeriodCycles =
product.introductoryPrice?.cycles;
final introductoryPricePeriodUnits =
product.introductoryPrice?.periodUnit;
final introductoryPeriodDesc =
introductoryPricePeriodCycles == null ||
introductoryPricePeriodUnits == null ||
introductoryPricePeriodUnits == PeriodUnit.unknown
? null
: _introductoryPeriodDescription(
introductoryPricePeriodCycles,
introductoryPricePeriodUnits);
final introductoryPeriodFullDesc = '$introductoryPriceInfo'
'${introductoryPeriodDesc == null ? '' //
: ' $introductoryPeriodDesc'}'
', then $priceInfo';
print('Introductory Price: $introductoryPeriodFullDesc');
}
}
}
} else {
logger.w('No offerings available');
showSnackBar('Subscriptions not working, contact support');
}
} catch (e) {
logger.e('Error fetching offerings', error: e);
showSnackBar('Error getting subscription info');
}
// showFullScreenModal(
// child: const SubscriptionPage(),
// );
}
The output of that code on my offerings, for reference:
I/flutter ( 7194): Package Type: PackageType.monthly
I/flutter ( 7194): Price: $29.99/mo
I/flutter ( 7194): Introductory Price: $17.99/mo for 6mo, then $29.99/mo
I/flutter ( 7194): Package Type: PackageType.threeMonth
I/flutter ( 7194): Price: $69.99/3mo
I/flutter ( 7194): Price converted to monthly: $23.32/mo (save 22%)
I/flutter ( 7194): Package Type: PackageType.annual
I/flutter ( 7194): Price: $239.99/yr
I/flutter ( 7194): Price converted to monthly: $19.99/mo (save 33%)
@lukehutch Thanks so much for the detailed explanation - this is really helpful.
While this is not a solution to the overall problem, would something like this make things simpler for you? Note: this does require the intl package to be installed in your project intl: ^0.19.0
:
String getPriceString({required double price, required String currencyCode}) {
final currencySymbol = NumberFormat.simpleCurrency(name: currencyCode).currencySymbol;
return NumberFormat.currency(symbol: currencySymbol).format(price);
}
You can get the currencyCode from the selected package: final currencyCode = package.storeProduct.currencyCode
.
@Jethro87 This helps a little bit, because then I could use the floating point price instead, but it doesn't tell me:
,
or .
for the cents separator (many Eurozone countries use ,
);0
; in some currencies, there are two decimal digits for cents, and in others there are not; etc.) -- just reporting double
, either rounded to the nearest int or rounded to 2dp, could result in some prices that look strange to users;I am relying on these problems already being solved by using priceString
, but I haven't actually checked to see if priceString
addresses any of these properly.
The Play Store and the App Store already solve these issues when you set prices. For example, if you set a price to $79.99 in the App Store, in South Korea the price will be shown as something like 110,000 KRW (in most currencies with large numbers like this, they round to the nearest thousand).
You really need to run some thorough experiments with the store APIs for each app store to see how they round and format numbers, because also if RevenueCat shows a price as say 259,990 KRW and an app store rounds it to 260,000 KRW (for example), users may be upset when they go from the paywall page to the platform subscription modal, even if the difference in price is small.
It is very important for RevenueCat to get this right for its API users. You can't expect every user of the RevenueCat API to reinvent the wheel to solve all these complex problems themselves, every time. (And not everybody wants to use the RevenueCat Paywall UI API -- I don't, because the paywalls are ugly and they flicker (FOUC) on Flutter.)
Right now to display a price, you have to use
product.priceString
, followed by some custom string building to add a suffix fromproduct.subscriptionPeriod
, like'/month'
or'/3mo'
. However, this doesn't internationalize well -- and every app should not have to reinvent the wheel on this. Also, things get much more complex when there is an introductory offer, etc.I assume that RevenueCat has some properly internationalized string rendering code for this, because the Paywalls library renders strings in this way. However, this does not seem to be exposed through the normal Dart API, and I don't want to use the Paywalls library because it is very janky (it renders as an Android fragment, not as a Flutter view -- it has a lot of flickering issues, and the layout is not great, so I'm just going to write my own paywall view).
Can you please provide some methods in the Dart API to properly render prices, billing periods, introductory prices, etc. into string form, with proper internationalization?