RevenueCat / purchases-flutter

Flutter plugin for in-app purchases and subscriptions. Supports iOS, macOS and Android.
https://www.revenuecat.com/
MIT License
595 stars 162 forks source link

Please add API for internationalization of pricing information #1088

Open lukehutch opened 1 month ago

lukehutch commented 1 month ago

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?

RCGitBot commented 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!

lukehutch commented 1 month ago

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.

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 commented 1 month ago

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 commented 1 month ago

@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 commented 1 month ago

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