Skip to content
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

Open
lukehutch opened this issue May 30, 2024 · 5 comments
Open

Please add API for internationalization of pricing information #1088

lukehutch opened this issue May 30, 2024 · 5 comments
Labels
enhancement New feature or request

Comments

@lukehutch
Copy link

Right now to display a price, you have to use product.priceString, followed by some custom string building to add a suffix from product.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?

@lukehutch lukehutch added the enhancement New feature or request label May 30, 2024
@RCGitBot
Copy link
Contributor

👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!

@lukehutch
Copy link
Author

lukehutch commented May 30, 2024

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.

  • Currently the price is provided as a 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).
    • So what I have to do is extract just the price digits and , 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.
  • I need to be able to show complex, fully internationalized renderings of various common pricing scenarios, and for now I have to just do all this in English because I don't have the resources to figure out the internationalization of this:
    • '$29.99/mo'
    • '$19.99/mo for the first 3mo (save 33%), then $29.99/mo'
    • '$249.99/year ($20.83/mo, save 41%)'
    • etc.
  • Not only would it be nice to have a string rendering of the whole offer ('$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(),
  // );
}

@lukehutch
Copy link
Author

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%)

@Jethro87
Copy link

Jethro87 commented Jun 3, 2024

@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.

@lukehutch
Copy link
Author

lukehutch commented Jun 5, 2024

@Jethro87 This helps a little bit, because then I could use the floating point price instead, but it doesn't tell me:

  • whether I should use , or . for the cents separator (many Eurozone countries use ,);
  • whether by locale convention a price should have thousands separators or not (the opposite of the decimal separator), or even a ten-thousands separator (which is common in some Asian countries);
  • how many significant figures or decimal places to use (in some currencies, the last 3 digits are always 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;
  • whether the currency symbol should come before or after the price (I think in some locales, the currency symbol comes after the price?).

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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants