-
Notifications
You must be signed in to change notification settings - Fork 170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Please add API for internationalization of pricing information #1088
Comments
👀 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.
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:
|
@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
You can get the currencyCode from the selected package: |
@Jethro87 This helps a little bit, because then I could use the floating point price instead, but it doesn't tell me:
I am relying on these problems already being solved by using 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?
The text was updated successfully, but these errors were encountered: