From 0487dbdcdecf81f0f283d95a3de36920a920abad Mon Sep 17 00:00:00 2001 From: Rico Berger Date: Thu, 12 Oct 2023 19:53:11 +0200 Subject: [PATCH] [core] Add In-App Purchases (#24) We have to add in-app purchases for the iOS, macOS and Android store, so that users can also get the premium features of the app without using Stripe for payments. The in-app purchases are only enabled when a user uses the app with the default Supabase environment or with the Supabase environment provided during build time. If a user uses his own Supabase instance, he will not be able to upgrade to the premium tier via in-app purchases. We are using RevenueCat for in-app purchases, which automatically sends all the events for a user to the "revenuecat-webhooks-v1" edge function. Depending on the received event we can then upgrade / downgrade the users profile. To be able to use RevenueCat as an additional provider to Stripe we also had to add a new "subscriptionProvider" provider column to the "profiles" table, which stores the information via which provider a user upgraded his account. --- .github/workflows/continuous-delivery.yaml | 2 + CONTRIBUTING.md | 1 + app/ios/Podfile.lock | 14 + app/ios/Runner.xcodeproj/project.pbxproj | 4 + app/lib/models/profile.dart | 41 +++ app/lib/repositories/profile_repository.dart | 7 + app/lib/repositories/settings_repository.dart | 17 ++ .../settings/premium/settings_premium.dart | 207 +++++++++++++++ .../premium/settings_premium_inapp.dart | 250 ++++++++++++++++++ .../settings_premium_inapp_restore.dart | 198 ++++++++++++++ .../settings_premium_stripe.dart} | 118 +-------- .../settings_profile_customer_portal.dart | 150 ++++++----- app/lib/widgets/settings/settings.dart | 4 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + app/macos/Podfile | 2 +- app/macos/Podfile.lock | 18 +- app/macos/Runner.xcodeproj/project.pbxproj | 7 + app/pubspec.lock | 16 ++ app/pubspec.yaml | 3 +- landing/app/pricing/page.tsx | 11 +- supabase/.env.example | 1 + supabase/functions/_shared/models/profile.ts | 1 + supabase/functions/_shared/stripe/stripe.ts | 1 + supabase/functions/_shared/utils/constants.ts | 4 + supabase/functions/profile-v1/index.ts | 1 + .../functions/revenuecat-webhooks-v1/index.ts | 174 ++++++++++++ ...1752_add_profile_fields_for_revenuecat.sql | 7 + 27 files changed, 1071 insertions(+), 190 deletions(-) create mode 100644 app/lib/widgets/settings/premium/settings_premium.dart create mode 100644 app/lib/widgets/settings/premium/settings_premium_inapp.dart create mode 100644 app/lib/widgets/settings/premium/settings_premium_inapp_restore.dart rename app/lib/widgets/settings/{settings_payment_banner.dart => premium/settings_premium_stripe.dart} (55%) create mode 100644 supabase/functions/revenuecat-webhooks-v1/index.ts create mode 100644 supabase/migrations/20230922191752_add_profile_fields_for_revenuecat.sql diff --git a/.github/workflows/continuous-delivery.yaml b/.github/workflows/continuous-delivery.yaml index 1d56240..efa5006 100644 --- a/.github/workflows/continuous-delivery.yaml +++ b/.github/workflows/continuous-delivery.yaml @@ -95,6 +95,7 @@ jobs: supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-checkout-session-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json + supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json - name: Push Database Migration and Deploy Functions if: ${{ github.event_name == 'release' && github.event.action == 'published' }} @@ -115,6 +116,7 @@ jobs: supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-checkout-session-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json + supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json # The "Web" job builds the Flutter web app and publishes it to Cloudflare Pages. The job only runs when a commit is # pushed to the main branch or a new tag is created. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b0ee04..e8f300f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -329,6 +329,7 @@ supabase functions deploy profile-v1 --project-ref --import-map sup supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-checkout-session-v1 --project-ref --import-map supabase/functions/import_map.json supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref --import-map supabase/functions/import_map.json +supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref --import-map supabase/functions/import_map.json ``` Now we have to do some manual steps to finish the setup of our Supabase project: diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 295895d..d40999c 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -18,6 +18,12 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - purchases_flutter (5.7.0): + - Flutter + - PurchasesHybridCommon (= 6.2.0) + - PurchasesHybridCommon (6.2.0): + - RevenueCat (= 4.26.0) + - RevenueCat (4.26.0) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -40,6 +46,7 @@ DEPENDENCIES: - just_audio (from `.symlinks/plugins/just_audio/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) + - purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) @@ -49,6 +56,8 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - PurchasesHybridCommon + - RevenueCat EXTERNAL SOURCES: app_links: @@ -67,6 +76,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/ios" + purchases_flutter: + :path: ".symlinks/plugins/purchases_flutter/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/ios" sign_in_with_apple: @@ -88,6 +99,9 @@ SPEC CHECKSUMS: just_audio: baa7252489dbcf47a4c7cc9ca663e9661c99aafa package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 + purchases_flutter: 9b875ca9540663d7cdaad9e574a65bd98a1e014c + PurchasesHybridCommon: 8ac2a833eb1a27c4d0d8a352418f1eaa5f4fe68d + RevenueCat: 1f3a5a1c3899cb27ef9279d77a01a807d489a240 shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472 sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index be2a8f7..83cd33d 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 11A5CB70E48EE53811F041B1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E4F63A76E5CF724D941DA24D /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 55F35B592ABF74D1007331B3 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55F35B582ABF74D1007331B3 /* StoreKit.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -34,6 +35,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 551175C42A39020E00A80299 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 55F35B582ABF74D1007331B3 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 5C1B3914BEEA60B960FC050D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -56,6 +58,7 @@ buildActionMask = 2147483647; files = ( 11A5CB70E48EE53811F041B1 /* Pods_Runner.framework in Frameworks */, + 55F35B592ABF74D1007331B3 /* StoreKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -121,6 +124,7 @@ AC99711BA86FAD05300ADB7E /* Frameworks */ = { isa = PBXGroup; children = ( + 55F35B582ABF74D1007331B3 /* StoreKit.framework */, E4F63A76E5CF724D941DA24D /* Pods_Runner.framework */, ); name = Frameworks; diff --git a/app/lib/models/profile.dart b/app/lib/models/profile.dart index 3d0c098..434e267 100644 --- a/app/lib/models/profile.dart +++ b/app/lib/models/profile.dart @@ -29,6 +29,40 @@ FDProfileTier getProfileTierFromString(String state) { return FDProfileTier.free; } +/// [FDProfileSubscriptionProvider] is a enum value which defines the different +/// subscription providers for a profile. A user can use [stripe] or +/// [revenuecat] to get a premium account. +enum FDProfileSubscriptionProvider { + stripe, + revenuecat, +} + +/// [FDProfileSubscriptionProviderExtension] defines all extensions which are +/// available for the [FDProfileSubscriptionProvider] enum type. +extension FDProfileSubscriptionProviderExtension + on FDProfileSubscriptionProvider { + /// [toShortString] returns a short string of the source type which can safely + /// be passed to our database. + String toShortString() { + return toString().split('.').last; + } +} + +/// [getSourceTypeFromString] returns the [FDProfileSubscriptionProvider] from +/// his string representation. This is used to parse the JSON value returned by +/// our database into correct enum value in the [FDSource] model. +FDProfileSubscriptionProvider? getProfileSubscriptionProviderFromString( + String state, +) { + for (FDProfileSubscriptionProvider element + in FDProfileSubscriptionProvider.values) { + if (element.toShortString() == state) { + return element; + } + } + return null; +} + /// [FDProfile] is the model for a profile of a user in our app. The following /// fields are required for a profile: /// - An [id] to uniquely identify a column in the database @@ -38,6 +72,7 @@ FDProfileTier getProfileTierFromString(String state) { class FDProfile { String id; FDProfileTier tier; + FDProfileSubscriptionProvider? subscriptionProvider; bool accountGithub; int createdAt; int updatedAt; @@ -45,6 +80,7 @@ class FDProfile { FDProfile({ required this.id, required this.tier, + required this.subscriptionProvider, required this.accountGithub, required this.createdAt, required this.updatedAt, @@ -54,6 +90,11 @@ class FDProfile { return FDProfile( id: data['id'], tier: getProfileTierFromString(data['tier']), + subscriptionProvider: data.containsKey('subscriptionProvider') && + data['subscriptionProvider'] != null + ? getProfileSubscriptionProviderFromString( + data['subscriptionProvider']) + : null, accountGithub: data['accountGithub'], createdAt: data['createdAt'], updatedAt: data['updatedAt'], diff --git a/app/lib/repositories/profile_repository.dart b/app/lib/repositories/profile_repository.dart index 76bf8b2..6fd7d3b 100644 --- a/app/lib/repositories/profile_repository.dart +++ b/app/lib/repositories/profile_repository.dart @@ -19,6 +19,8 @@ class ProfileRepository with ChangeNotifier { FDProfileStatus get status => _status; FDProfileTier get tier => _profile?.tier ?? FDProfileTier.free; + FDProfileSubscriptionProvider? get subscriptionProvider => + _profile?.subscriptionProvider; bool get accountGithub => _profile?.accountGithub ?? false; Future init(bool force) async { @@ -45,6 +47,11 @@ class ProfileRepository with ChangeNotifier { } } + void setTier(FDProfileTier tier) { + _profile?.tier = tier; + notifyListeners(); + } + Future addGithubAccount(String token) async { final result = await Supabase.instance.client.functions.invoke( 'profile-v1', diff --git a/app/lib/repositories/settings_repository.dart b/app/lib/repositories/settings_repository.dart index e172697..26509ed 100644 --- a/app/lib/repositories/settings_repository.dart +++ b/app/lib/repositories/settings_repository.dart @@ -19,6 +19,18 @@ class SettingsRepository { String googleClientId = '420185423235-9ehth1eodl4lt3cdns7kaf2e89eo6rkq.apps.googleusercontent.com'; + /// By default the [subscriptionEnabled] variable is set to `true`, so that a + /// user can subscribe to FeedDeck Premium. If the variable is set to `false` + /// the user can not subscribe to FeedDeck Premium. + bool subscriptionEnabled = true; + + /// The [revenueCatAppStoreKey] and [revenueCatGooglePlayKey] are used for the + /// in-app purchases. The [revenueCatAppStoreKey] is used for the Apple App + /// Store on iOS and macOS. The [revenueCatGooglePlayKey] is used for the + /// Google Play Store on Android. + final String revenueCatAppStoreKey = 'appl_kThbIaMkylDBtCEmsfczvgCBram'; + final String revenueCatGooglePlayKey = 'goog_tBFPbLbygjioviXRIlGlmUOKZYA'; + factory SettingsRepository() { return _instance; } @@ -52,11 +64,16 @@ class SettingsRepository { googleClientIdPrefs != null) { /// Store the user provided values within the [SettingsRepository] and /// use them to initialize the Supabase client. + /// + /// Also set the [subscriptionEnabled] variabel to `false`, so that the + /// user can not subscribe to FeedDeck Premium. supabaseUrl = supabaseUrlPrefs; supabaseAnonKey = supabaseAnonKeyPrefs; supabaseSiteUrl = supabaseSiteUrlPrefs; googleClientId = googleClientIdPrefs; + subscriptionEnabled = false; + await Supabase.initialize( url: supabaseUrlPrefs, anonKey: supabaseAnonKeyPrefs, diff --git a/app/lib/widgets/settings/premium/settings_premium.dart b/app/lib/widgets/settings/premium/settings_premium.dart new file mode 100644 index 0000000..a67a0f1 --- /dev/null +++ b/app/lib/widgets/settings/premium/settings_premium.dart @@ -0,0 +1,207 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:feeddeck/models/profile.dart'; +import 'package:feeddeck/repositories/profile_repository.dart'; +import 'package:feeddeck/repositories/settings_repository.dart'; +import 'package:feeddeck/utils/constants.dart'; +import 'package:feeddeck/utils/fd_icons.dart'; +import 'package:feeddeck/widgets/general/logo.dart'; +import 'package:feeddeck/widgets/settings/premium/settings_premium_inapp.dart'; +import 'package:feeddeck/widgets/settings/premium/settings_premium_inapp_restore.dart'; +import 'package:feeddeck/widgets/settings/premium/settings_premium_stripe.dart'; + +class SettingsPremium extends StatelessWidget { + const SettingsPremium({super.key}); + + /// [_showPaymentModal] show a modal to subscribe to FeedDeck Premium via + /// Stripe on the web, Linux and Windows. On macOS, Android and iOS the modal + /// to subscribe via in-app purchases is shown. + void _showPaymentModal(BuildContext context) { + if (kIsWeb || Platform.isLinux || Platform.isWindows) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.spacingMiddle), + ), + ), + clipBehavior: Clip.antiAliasWithSaveLayer, + constraints: const BoxConstraints( + maxWidth: Constants.centeredFormMaxWidth, + ), + builder: (BuildContext context) { + return const SettingsPremiumStripe(); + }, + ); + } else if (Platform.isMacOS || Platform.isAndroid || Platform.isIOS) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.spacingMiddle), + ), + ), + clipBehavior: Clip.antiAliasWithSaveLayer, + constraints: const BoxConstraints( + maxWidth: Constants.centeredFormMaxWidth, + ), + builder: (BuildContext context) { + return const SettingsPremiumInApp(); + }, + ); + } + } + + @override + Widget build(BuildContext context) { + ProfileRepository profile = Provider.of( + context, + listen: true, + ); + + /// If subscriptions are disabled, because the user uses a custom Supabase + /// instance or if the profile is not initialized yet or if the user is + /// already on the premium tier we do not show the option to subscribe to + /// FeedDeck Premium. + if (!SettingsRepository().subscriptionEnabled) { + return Container(); + } + + if (profile.status == FDProfileStatus.uninitialized) { + return Container(); + } + + /// In-App Purchases are disabled on Android for now, until we have the + /// first version of the app in the Play Store, so that we can properly test + /// the implementation. + /// + /// TODO: Enable once the first Android version is in the Play Store. + if (!kIsWeb && Platform.isAndroid) { + return Container(); + } + + if (profile.tier != FDProfileTier.free) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + color: Constants.primary, + margin: const EdgeInsets.only( + bottom: Constants.spacingSmall, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all( + Constants.spacingMiddle, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Characters('You are using FeedDeck Premium') + .replaceAll( + Characters(''), + Characters('\u{200B}'), + ) + .toString(), + maxLines: 1, + style: const TextStyle( + color: Constants.onPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const Icon( + FDIcons.feeddeck, + color: Constants.onPrimary, + ), + ], + ), + ), + ], + ), + ), + const SizedBox( + height: Constants.spacingMiddle, + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _showPaymentModal(context), + child: Card( + color: Constants.primary, + margin: const EdgeInsets.only( + bottom: Constants.spacingSmall, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all( + Constants.spacingMiddle, + ), + child: Column( + children: const [ + Logo(size: 64), + SizedBox( + height: Constants.spacingSmall, + ), + Text( + 'Subscribe to FeedDeck Premium', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Constants.onPrimary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + const SettingsPremiumInAppRestore(), + const SizedBox( + height: Constants.spacingMiddle, + ), + ], + ); + } +} diff --git a/app/lib/widgets/settings/premium/settings_premium_inapp.dart b/app/lib/widgets/settings/premium/settings_premium_inapp.dart new file mode 100644 index 0000000..7a10537 --- /dev/null +++ b/app/lib/widgets/settings/premium/settings_premium_inapp.dart @@ -0,0 +1,250 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; + +import 'package:feeddeck/models/profile.dart'; +import 'package:feeddeck/repositories/profile_repository.dart'; +import 'package:feeddeck/repositories/settings_repository.dart'; +import 'package:feeddeck/utils/constants.dart'; +import 'package:feeddeck/utils/fd_icons.dart'; +import 'package:feeddeck/widgets/general/elevated_button_progress_indicator.dart'; + +class SettingsPremiumInApp extends StatefulWidget { + const SettingsPremiumInApp({super.key}); + + @override + State createState() => _SettingsPremiumInAppState(); +} + +class _SettingsPremiumInAppState extends State { + late Future _futureFetchOfferings; + bool _isLoading = false; + + /// [_fetchOfferings] is used to fetch the Stripe checkout session + /// link. For that we have to call the `stripe-create-checkout-session-v1` + /// Supabase edge function. If the link is generated successfully, the + /// function returns the url, which can then be opened by the user. + Future _fetchOfferings() async { + if (Platform.isAndroid) { + await Purchases.configure( + PurchasesConfiguration( + SettingsRepository().revenueCatAppStoreKey, + )..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id, + ); + } else if (Platform.isMacOS || Platform.isIOS) { + await Purchases.configure( + PurchasesConfiguration( + SettingsRepository().revenueCatAppStoreKey, + )..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id, + ); + } + + Offerings offerings = await Purchases.getOfferings(); + if (offerings.current != null) { + return offerings.current; + } else { + return null; + } + } + + /// [_purchase] is used to purchase the provided [package]. If the purchase + /// was successful, the user is notified. If the purchase failed, the user is + /// notified as well. + Future _purchase(Package package) async { + try { + setState(() { + _isLoading = true; + }); + + CustomerInfo customerInfo = await Purchases.purchasePackage(package); + setState(() { + _isLoading = false; + }); + + if (customerInfo.entitlements.all['FeedDeck Premium']!.isActive) { + if (!mounted) return; + Provider.of( + context, + listen: false, + ).setTier(FDProfileTier.premium); + } + + if (!mounted) return; + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 10), + backgroundColor: Constants.primary, + showCloseIcon: true, + content: Text( + 'FeedDeck Premium was successfully purchased.', + style: TextStyle(color: Constants.onPrimary), + ), + ), + ); + } on PlatformException catch (err) { + final errorCode = PurchasesErrorHelper.getErrorCode(err); + if (errorCode == PurchasesErrorCode.purchaseCancelledError) { + setState(() { + _isLoading = false; + }); + Navigator.of(context).pop(); + } else { + setState(() { + _isLoading = false; + }); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 10), + backgroundColor: Constants.error, + showCloseIcon: true, + content: Text( + 'In-app purchase failed: ${err.message}', + style: const TextStyle(color: Constants.onError), + ), + ), + ); + } + } catch (err) { + setState(() { + _isLoading = false; + }); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 10), + backgroundColor: Constants.error, + showCloseIcon: true, + content: Text( + 'In-app purchase failed: ${err.toString()}', + style: const TextStyle(color: Constants.onError), + ), + ), + ); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + setState(() { + _futureFetchOfferings = _fetchOfferings(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + shape: const Border( + bottom: BorderSide( + color: Constants.dividerColor, + width: 1, + ), + ), + title: const Text('FeedDeck Premium'), + actions: [ + IconButton( + icon: const Icon( + Icons.close, + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + body: FutureBuilder( + future: _futureFetchOfferings, + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(Constants.spacingMiddle), + child: SingleChildScrollView( + child: snapshot.connectionState == ConnectionState.none || + snapshot.connectionState == + ConnectionState.waiting || + snapshot.hasError || + snapshot.data == null || + snapshot.data?.monthly == null + ? const Text('Loading ...') + : MarkdownBody( + selectable: true, + data: ''' +You are currently using the free version of FeedDeck, which allows you to add up +to 10 sources for the first 7 days. After that trial period your sources will +not be updated anymore. + +To use FeedDeck after the trial period with up to 1000 sources, you need to +upgrade to a premium account. The premium account costs +${snapshot.data?.monthly?.storeProduct.priceString} per month and can be +canceled at any time. +''', + ), + ), + ), + ), + const SizedBox( + height: Constants.spacingSmall, + ), + const Divider( + color: Constants.dividerColor, + height: 1, + thickness: 1, + ), + Padding( + padding: const EdgeInsets.all(Constants.spacingMiddle), + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Constants.primary, + foregroundColor: Constants.onPrimary, + maximumSize: const Size.fromHeight( + Constants.elevatedButtonSize, + ), + minimumSize: const Size.fromHeight( + Constants.elevatedButtonSize, + ), + ), + label: Text( + snapshot.data?.monthly?.storeProduct.priceString != null + ? 'Subscribe to FeedDeck Premium for ${snapshot.data?.monthly?.storeProduct.priceString}' + : 'Subscribe to FeedDeck Premium', + ), + onPressed: snapshot.connectionState == ConnectionState.none || + snapshot.connectionState == ConnectionState.waiting || + snapshot.hasError || + snapshot.data == null || + snapshot.data?.monthly == null || + _isLoading + ? null + : () => _purchase(snapshot.data!.monthly!), + icon: snapshot.connectionState == ConnectionState.none || + snapshot.connectionState == ConnectionState.waiting || + snapshot.hasError || + snapshot.data == null || + snapshot.data?.monthly == null || + _isLoading + ? const ElevatedButtonProgressIndicator() + : const Icon(FDIcons.feeddeck), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/widgets/settings/premium/settings_premium_inapp_restore.dart b/app/lib/widgets/settings/premium/settings_premium_inapp_restore.dart new file mode 100644 index 0000000..ec7aa34 --- /dev/null +++ b/app/lib/widgets/settings/premium/settings_premium_inapp_restore.dart @@ -0,0 +1,198 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:provider/provider.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; + +import 'package:feeddeck/models/profile.dart'; +import 'package:feeddeck/repositories/profile_repository.dart'; +import 'package:feeddeck/repositories/settings_repository.dart'; +import 'package:feeddeck/utils/constants.dart'; +import 'package:feeddeck/widgets/general/elevated_button_progress_indicator.dart'; + +class SettingsPremiumInAppRestore extends StatefulWidget { + const SettingsPremiumInAppRestore({super.key}); + + @override + State createState() => + _SettingsPremiumInAppRestoreState(); +} + +class _SettingsPremiumInAppRestoreState + extends State { + bool _isLoading = false; + + Future _restore() async { + try { + setState(() { + _isLoading = true; + }); + + if (Platform.isAndroid) { + await Purchases.configure( + PurchasesConfiguration( + SettingsRepository().revenueCatAppStoreKey, + )..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id, + ); + } else if (Platform.isMacOS || Platform.isIOS) { + await Purchases.configure( + PurchasesConfiguration( + SettingsRepository().revenueCatAppStoreKey, + )..appUserID = supabase.Supabase.instance.client.auth.currentUser!.id, + ); + } else { + return; + } + + CustomerInfo customerInfo = await Purchases.restorePurchases(); + setState(() { + _isLoading = false; + }); + + if (customerInfo.entitlements.all['FeedDeck Premium']!.isActive) { + if (!mounted) return; + Provider.of( + context, + listen: false, + ).setTier(FDProfileTier.premium); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 10), + backgroundColor: Constants.primary, + showCloseIcon: true, + content: Text( + 'FeedDeck Premium was restored.', + style: TextStyle(color: Constants.onPrimary), + ), + ), + ); + } on PlatformException catch (err) { + setState(() { + _isLoading = false; + }); + final errorCode = PurchasesErrorHelper.getErrorCode(err); + if (errorCode == PurchasesErrorCode.purchaseCancelledError) { + return; + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 10), + backgroundColor: Constants.error, + showCloseIcon: true, + content: Text( + 'Restore purchase failed: ${err.message}', + style: const TextStyle(color: Constants.onError), + ), + ), + ); + } + } catch (err) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 10), + backgroundColor: Constants.error, + showCloseIcon: true, + content: Text( + 'Restore purchase failed: ${err.toString()}', + style: const TextStyle(color: Constants.onError), + ), + ), + ); + } + } + + /// [buildIcon] return the provided icon or when the [_isLoading] state is + /// `true` is returns a circular progress indicator. + Widget buildIcon() { + if (_isLoading) return const ElevatedButtonProgressIndicator(); + return Container(); + } + + @override + Widget build(BuildContext context) { + ProfileRepository profile = Provider.of( + context, + listen: true, + ); + + /// We do not display the restore button when subscriptions are disabled, + /// the profile isn't initalized yet or a user is already subscribed to + /// FeedDeck Premium. + if (!SettingsRepository().subscriptionEnabled) { + return Container(); + } + + if (profile.status == FDProfileStatus.uninitialized) { + return Container(); + } + + if (profile.tier != FDProfileTier.free) { + return Container(); + } + + if (!kIsWeb && (Platform.isMacOS || Platform.isAndroid || Platform.isIOS)) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _restore(), + child: Card( + color: Constants.secondary, + margin: const EdgeInsets.only( + bottom: Constants.spacingSmall, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all( + Constants.spacingMiddle, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Characters('Restore Purchases') + .replaceAll( + Characters(''), + Characters('\u{200B}'), + ) + .toString(), + maxLines: 1, + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + buildIcon(), + ], + ), + ), + ], + ), + ), + ), + ); + } + + return Container(); + } +} diff --git a/app/lib/widgets/settings/settings_payment_banner.dart b/app/lib/widgets/settings/premium/settings_premium_stripe.dart similarity index 55% rename from app/lib/widgets/settings/settings_payment_banner.dart rename to app/lib/widgets/settings/premium/settings_premium_stripe.dart index e5de121..4db3c11 100644 --- a/app/lib/widgets/settings/settings_payment_banner.dart +++ b/app/lib/widgets/settings/premium/settings_premium_stripe.dart @@ -1,134 +1,32 @@ -import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:provider/provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart' as supabase; -import 'package:feeddeck/models/profile.dart'; -import 'package:feeddeck/repositories/profile_repository.dart'; import 'package:feeddeck/utils/api_exception.dart'; import 'package:feeddeck/utils/constants.dart'; import 'package:feeddeck/utils/fd_icons.dart'; import 'package:feeddeck/utils/openurl.dart'; import 'package:feeddeck/widgets/general/elevated_button_progress_indicator.dart'; -import 'package:feeddeck/widgets/general/logo.dart'; -const _settingsPaymentBannerText = ''' +const _settingsPremiumStripeText = ''' You are currently using the free version of FeedDeck, which allows you to add up to 10 sources for the first 7 days. After that trial period your sources will not be updated anymore. -To use FeedDeck after the trial period with up to 1000 sources you need to +To use FeedDeck after the trial period with up to 1000 sources, you need to upgrade to a premium account. The premium account costs 5€ per month and can be canceled at any time. '''; -class SettingsPaymentBanner extends StatelessWidget { - const SettingsPaymentBanner({super.key}); +class SettingsPremiumStripe extends StatefulWidget { + const SettingsPremiumStripe({super.key}); @override - Widget build(BuildContext context) { - ProfileRepository profile = Provider.of( - context, - listen: true, - ); - - if (profile.status == FDProfileStatus.uninitialized) { - return Container(); - } - - if (!kIsWeb) { - return Container(); - } - - if (profile.tier != FDProfileTier.free) { - return Container(); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(Constants.spacingMiddle), - ), - ), - clipBehavior: Clip.antiAliasWithSaveLayer, - constraints: const BoxConstraints( - maxWidth: Constants.centeredFormMaxWidth, - ), - builder: (BuildContext context) { - return const SettingsPaymentBannerModal(); - }, - ); - }, - child: Card( - color: Constants.primary, - margin: const EdgeInsets.only( - bottom: Constants.spacingSmall, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all( - Constants.spacingMiddle, - ), - child: Column( - children: const [ - Logo(size: 64), - SizedBox( - height: Constants.spacingSmall, - ), - Text( - 'Subscribe to FeedDeck Premium', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Constants.onPrimary, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - const SizedBox( - height: Constants.spacingMiddle, - ), - ], - ); - } + State createState() => _SettingsPremiumStripeState(); } -class SettingsPaymentBannerModal extends StatefulWidget { - const SettingsPaymentBannerModal({super.key}); - - @override - State createState() => - _SettingsPaymentBannerModalState(); -} - -class _SettingsPaymentBannerModalState - extends State { +class _SettingsPremiumStripeState extends State { late Future _futureFetchCheckoutSessionLink; /// [_fetchCheckoutSessionLink] is used to fetch the Stripe checkout session @@ -177,7 +75,7 @@ class _SettingsPaymentBannerModalState width: 1, ), ), - title: const Text('Subscribe to FeedDeck Premium'), + title: const Text('FeedDeck Premium'), actions: [ IconButton( icon: const Icon( @@ -203,7 +101,7 @@ class _SettingsPaymentBannerModalState child: SingleChildScrollView( child: MarkdownBody( selectable: true, - data: _settingsPaymentBannerText, + data: _settingsPremiumStripeText, ), ), ), diff --git a/app/lib/widgets/settings/profile/settings_profile_customer_portal.dart b/app/lib/widgets/settings/profile/settings_profile_customer_portal.dart index 3a1e19c..2642b69 100644 --- a/app/lib/widgets/settings/profile/settings_profile_customer_portal.dart +++ b/app/lib/widgets/settings/profile/settings_profile_customer_portal.dart @@ -1,9 +1,11 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:provider/provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import 'package:feeddeck/models/profile.dart'; +import 'package:feeddeck/repositories/profile_repository.dart'; import 'package:feeddeck/utils/api_exception.dart'; import 'package:feeddeck/utils/constants.dart'; import 'package:feeddeck/utils/openurl.dart'; @@ -22,80 +24,86 @@ class SettingsProfileCustomerPortal extends StatelessWidget { @override Widget build(BuildContext context) { - if (!kIsWeb) { - return Container(); - } + ProfileRepository profile = Provider.of( + context, + listen: true, + ); - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(Constants.spacingMiddle), - ), - ), - clipBehavior: Clip.antiAliasWithSaveLayer, - constraints: const BoxConstraints( - maxWidth: Constants.centeredFormMaxWidth, - ), - builder: (BuildContext context) { - return const SettingsProfileCustomerPortalModal(); - }, - ); - }, - child: Card( - color: Constants.secondary, - margin: const EdgeInsets.only( - bottom: Constants.spacingSmall, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all( - Constants.spacingMiddle, + if (profile.tier == FDProfileTier.premium && + profile.subscriptionProvider == FDProfileSubscriptionProvider.stripe) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.spacingMiddle), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - Characters('Customer Portal') - .replaceAll( - Characters(''), - Characters('\u{200B}'), - ) - .toString(), - maxLines: 1, - style: const TextStyle( - overflow: TextOverflow.ellipsis, + ), + clipBehavior: Clip.antiAliasWithSaveLayer, + constraints: const BoxConstraints( + maxWidth: Constants.centeredFormMaxWidth, + ), + builder: (BuildContext context) { + return const SettingsProfileCustomerPortalModal(); + }, + ); + }, + child: Card( + color: Constants.secondary, + margin: const EdgeInsets.only( + bottom: Constants.spacingSmall, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all( + Constants.spacingMiddle, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Characters('Customer Portal') + .replaceAll( + Characters(''), + Characters('\u{200B}'), + ) + .toString(), + maxLines: 1, + style: const TextStyle( + overflow: TextOverflow.ellipsis, + ), ), - ), - ], + ], + ), ), - ), - const Icon(Icons.receipt), - ], + const Icon(Icons.receipt), + ], + ), ), - ), - ], + ], + ), ), ), - ), - ); + ); + } + + return Container(); } } @@ -119,9 +127,9 @@ class _SettingsProfileCustomerPortalModalState /// Supabase edge function. If the link is generated successfully, the /// function returns the url, which can then be opened by the user. Future _fetchCustomerPortalLink() async { - final result = await Supabase.instance.client.functions.invoke( + final result = await supabase.Supabase.instance.client.functions.invoke( 'stripe-create-billing-portal-link-v1', - method: HttpMethod.get, + method: supabase.HttpMethod.get, ); if (result.status != 200) { diff --git a/app/lib/widgets/settings/settings.dart b/app/lib/widgets/settings/settings.dart index 5acb227..9ebee56 100644 --- a/app/lib/widgets/settings/settings.dart +++ b/app/lib/widgets/settings/settings.dart @@ -6,9 +6,9 @@ import 'package:feeddeck/repositories/profile_repository.dart'; import 'package:feeddeck/utils/constants.dart'; import 'package:feeddeck/widgets/settings/accounts/settings_accounts.dart'; import 'package:feeddeck/widgets/settings/decks/settings_decks.dart'; +import 'package:feeddeck/widgets/settings/premium/settings_premium.dart'; import 'package:feeddeck/widgets/settings/profile/settings_profile.dart'; import 'package:feeddeck/widgets/settings/settings_info.dart'; -import 'package:feeddeck/widgets/settings/settings_payment_banner.dart'; /// The [Settings] widget implements the settings page for the FeedDeck app. The /// page is used to display all the users information and to provide a way where @@ -58,7 +58,7 @@ class _SettingsState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: const [ - SettingsPaymentBanner(), + SettingsPremium(), /// Display all decks. Here the user can switch the active /// deck, he can delete a deck or update the name of a diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index b9227d4..b41f7fc 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import audio_session import just_audio import package_info_plus import path_provider_foundation +import purchases_flutter import screen_retriever import shared_preferences_foundation import sign_in_with_apple @@ -25,6 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) diff --git a/app/macos/Podfile b/app/macos/Podfile index 049abe2..4e4cc6f 100644 --- a/app/macos/Podfile +++ b/app/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '11.00' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index 655fcae..dc6149b 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -16,6 +16,12 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - purchases_flutter (5.7.0): + - FlutterMacOS + - PurchasesHybridCommon (= 6.2.0) + - PurchasesHybridCommon (6.2.0): + - RevenueCat (= 4.26.0) + - RevenueCat (4.26.0) - screen_retriever (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -39,6 +45,7 @@ DEPENDENCIES: - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`) + - purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos`) - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`) @@ -49,6 +56,8 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - PurchasesHybridCommon + - RevenueCat EXTERNAL SOURCES: app_links: @@ -65,6 +74,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos + purchases_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos screen_retriever: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos shared_preferences_foundation: @@ -87,6 +98,9 @@ SPEC CHECKSUMS: just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 + purchases_flutter: a3e23d64646625d452ef542c3b6207f97a055c21 + PurchasesHybridCommon: 8ac2a833eb1a27c4d0d8a352418f1eaa5f4fe68d + RevenueCat: 1f3a5a1c3899cb27ef9279d77a01a807d489a240 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472 sign_in_with_apple: a9e97e744e8edc36aefc2723111f652102a7a727 @@ -94,6 +108,6 @@ SPEC CHECKSUMS: url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 086a133fa7149895148dfc1f080138c34e40ddbf -COCOAPODS: 1.12.1 +COCOAPODS: 1.13.0 diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj index 9712954..180ca55 100644 --- a/app/macos/Runner.xcodeproj/project.pbxproj +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 553BE2622AC0648A002EA0C0 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 553BE2612AC0648A002EA0C0 /* StoreKit.framework */; }; FBF50439F2C4D0BF615EFED2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27D60858A3CD81E41E2CDBF2 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -70,6 +71,7 @@ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 46A27EFECA16837D10C377EF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 553BE2612AC0648A002EA0C0 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 55A996D72AB9F2F200780025 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; @@ -82,6 +84,7 @@ buildActionMask = 2147483647; files = ( FBF50439F2C4D0BF615EFED2 /* Pods_Runner.framework in Frameworks */, + 553BE2622AC0648A002EA0C0 /* StoreKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -167,6 +170,7 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 553BE2612AC0648A002EA0C0 /* StoreKit.framework */, 27D60858A3CD81E41E2CDBF2 /* Pods_Runner.framework */, ); name = Frameworks; @@ -431,6 +435,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -560,6 +565,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -583,6 +589,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/app/pubspec.lock b/app/pubspec.lock index 4e668a8..de97244 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -280,6 +280,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" functions_client: dependency: transitive description: @@ -680,6 +688,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + purchases_flutter: + dependency: "direct main" + description: + name: purchases_flutter + sha256: fd28e581d4508aecb83f82768169115870fb6639ddb163d044e60cc5584227c8 + url: "https://pub.dev" + source: hosted + version: "5.7.0" realtime_client: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e51e0a9..6b45376 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+3 +version: 1.0.0+4 environment: sdk: '>=2.19.6 <3.0.0' @@ -51,6 +51,7 @@ dependencies: just_audio_mpv: ^0.1.6 package_info_plus: ^4.1.0 provider: ^6.0.4 + purchases_flutter: ^5.7.0 rxdart: ^0.27.7 scroll_to_index: ^3.0.1 shared_preferences: ^2.1.0 diff --git a/landing/app/pricing/page.tsx b/landing/app/pricing/page.tsx index 070be2e..c981e67 100644 --- a/landing/app/pricing/page.tsx +++ b/landing/app/pricing/page.tsx @@ -65,8 +65,9 @@ export default function Pricing() {
5€ - /month + / month{" "} + *
    @@ -129,6 +130,10 @@ export default function Pricing() { +
    + * The price for a premium account buyed via in-app purchase on iOS, + macOS or Android can be different due to the fees of the app stores. +
    ); } @@ -142,9 +147,9 @@ const Feature = ({ children }: { children: ReactNode }) => ( xmlns="http://www.w3.org/2000/svg" > diff --git a/supabase/.env.example b/supabase/.env.example index 0fbb809..6c6a54e 100644 --- a/supabase/.env.example +++ b/supabase/.env.example @@ -18,6 +18,7 @@ FEEDDECK_REDIS_PASSWORD= FEEDDECK_STRIPE_API_KEY= FEEDDECK_STRIPE_PRICE_ID= FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET= +FEEDDECK_REVENUECAT_WEBHOOK_HEADER= # Source Configuration FEEDDECK_SOURCE_NITTER_INSTANCE= diff --git a/supabase/functions/_shared/models/profile.ts b/supabase/functions/_shared/models/profile.ts index 5b0b5e9..cb98be9 100644 --- a/supabase/functions/_shared/models/profile.ts +++ b/supabase/functions/_shared/models/profile.ts @@ -1,6 +1,7 @@ export interface IProfile { id: string; tier: "free" | "premium"; + subscriptionProvider?: "stripe" | "revenuecat"; accountGithub?: { token?: string; }; diff --git a/supabase/functions/_shared/stripe/stripe.ts b/supabase/functions/_shared/stripe/stripe.ts index 80dc53f..32a7518 100644 --- a/supabase/functions/_shared/stripe/stripe.ts +++ b/supabase/functions/_shared/stripe/stripe.ts @@ -168,6 +168,7 @@ export const manageSubscriptionStatusChange = async ( const { error: updateError } = await adminSupabaseClient.from("profiles") .update({ tier: isCreated ? "premium" : "free", + subscriptionProvider: "stripe", }).eq("id", profile[0].id); if (updateError) { log("error", "Failed to update user profile with new tier value", { diff --git a/supabase/functions/_shared/utils/constants.ts b/supabase/functions/_shared/utils/constants.ts index 667a086..0e04693 100644 --- a/supabase/functions/_shared/utils/constants.ts +++ b/supabase/functions/_shared/utils/constants.ts @@ -53,6 +53,8 @@ export const FEEDDECK_REDIS_PASSWORD = * - FEEDDECK_STRIPE_API_KEY is the API key to access the Stripe API. * - FEEDDECK_STRIPE_PRICE_ID is the id of the price used for the subscription. * - FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET is the signing secret used to verify the Stripe webhook calls. + * - FEEDDECK_REVENUECAT_ENVIRONMENT is the Store environment, this could be "SANDBOX" or "PRODUCTION". + * - FEEDDECK_REVENUECAT_WEBHOOK_HEADER is the value of the authorization header send by RevenueCat. */ export const FEEDDECK_STRIPE_API_KEY = Deno.env.get("FEEDDECK_STRIPE_API_KEY") ?? ""; @@ -60,6 +62,8 @@ export const FEEDDECK_STRIPE_PRICE_ID = Deno.env.get("FEEDDECK_STRIPE_PRICE_ID") ?? ""; export const FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET = Deno.env.get("FEEDDECK_STRIPE_WEBHOOK_SIGNING_SECRET") ?? ""; +export const FEEDDECK_REVENUECAT_WEBHOOK_HEADER = + Deno.env.get("FEEDDECK_REVENUECAT_WEBHOOK_HEADER") ?? ""; /** * Source Configuration diff --git a/supabase/functions/profile-v1/index.ts b/supabase/functions/profile-v1/index.ts index 3aeffce..0555d6a 100644 --- a/supabase/functions/profile-v1/index.ts +++ b/supabase/functions/profile-v1/index.ts @@ -98,6 +98,7 @@ serve(async (req) => { JSON.stringify({ "id": (profile[0] as IProfile).id, "tier": (profile[0] as IProfile).tier, + "subscriptionProvider": (profile[0] as IProfile).subscriptionProvider, "accountGithub": (profile[0] as IProfile).accountGithub?.token ? true : false, diff --git a/supabase/functions/revenuecat-webhooks-v1/index.ts b/supabase/functions/revenuecat-webhooks-v1/index.ts new file mode 100644 index 0000000..607265e --- /dev/null +++ b/supabase/functions/revenuecat-webhooks-v1/index.ts @@ -0,0 +1,174 @@ +import { serve } from "std/server"; +import { createClient } from "@supabase/supabase-js"; + +import { log } from "../_shared/utils/log.ts"; +import { + FEEDDECK_REVENUECAT_WEBHOOK_HEADER, + FEEDDECK_SUPABASE_SERVICE_ROLE_KEY, + FEEDDECK_SUPABASE_URL, +} from "../_shared/utils/constants.ts"; + +/** + * The `IEventPayload` interface represents the payload of a RevenueCat webhook call. + */ +interface IEventPayload { + api_version: string; + event: IEvent; +} + +interface IEvent { + app_id: string; + app_user_id: string; + environment: string; + type: string; +} + +/** + * `isAuthorized` checks if the request is authorized. This is done by checking the authorization header of the request, + * which must match the configured header in RevenueCat. + */ +const isAuthorized = (req: Request): boolean => { + const authorizationHeader = req.headers.get("Authorization"); + + if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) { + return false; + } + + const authToken = authorizationHeader.split("Bearer ")[1]; + if (authToken !== FEEDDECK_REVENUECAT_WEBHOOK_HEADER) { + return false; + } + + return true; +}; + +/** + * `manageSubscriptionStatusChange` changes the subscription status of a user in the database. + */ +export const manageSubscriptionStatusChange = async ( + userId: string, + isCreated = false, +) => { + /** + * Create a new admin client for Supabase, which is used in the following steps to access the database. This client + * is required because the user client does not have the permissions to access the `profiles` table. + */ + const adminSupabaseClient = createClient( + FEEDDECK_SUPABASE_URL, + FEEDDECK_SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }, + ); + + /** + * Get the user profile from the database. If there is no profile or more than one profile, we return an error. + */ + const { data: profile, error: profileError } = await adminSupabaseClient + .from( + "profiles", + ) + .select("*").eq("id", userId); + if (profileError || profile?.length !== 1) { + log("error", "Failed to get user profile", { + "userId": userId, + "error": profileError, + }); + throw new Error("Failed to get user profile"); + } + + /** + * If the user is already on the correct tier, we return early. + */ + if ( + (profile[0].tier === "free" && !isCreated) || + (profile[0].tier === "premium" && isCreated) + ) { + return; + } + + const { error: updateError } = await adminSupabaseClient.from("profiles") + .update({ + tier: isCreated ? "premium" : "free", + subscriptionProvider: "revenuecat", + }).eq("id", profile[0].id); + if (updateError) { + log("error", "Failed to update user profile with new tier value", { + "userId": userId, + "error": updateError, + }); + throw new Error("Failed to update user profile with new tier value"); + } +}; + +/** + * The `revenuecat-webhooks-v1` edge function handles all incomming RevenueCat webhooks. When we a receive a new event, + * we have to change the users account tier to `premium` or to `free`, depending on the received event. + */ +serve(async (req) => { + try { + /** + * If the request method is not POST, we return a 403 Forbidden error. This is done because we only want to accept + * POST requests. If the request is not authorized, we return a 401 Unauthorized error. + */ + if (req.method !== "POST") { + return new Response( + "Forbidden", + { + status: 403, + }, + ); + } + + if (!isAuthorized(req)) { + return new Response( + "Unauthorized", + { + status: 401, + }, + ); + } + + /** + * Get the payload of the received webhook event. + */ + const payload = await req.json() as IEventPayload; + log("debug", "Received event", { "event": payload.event }); + + /** + * If the event type is `INITIAL_PURCHASE`, `RENEWAL` or `UNCANCELLATION`, we change the subscription status of the + * user to `premium`. If the event type is `EXPIRATION`, we change the subscription status of the user to `free`. + * All other event types are ignored. + */ + if ( + payload.event.type === "INITIAL_PURCHASE" || + payload.event.type === "RENEWAL" || + payload.event.type === "UNCANCELLATION" + ) { + await manageSubscriptionStatusChange(payload.event.app_user_id, true); + return new Response("ok", { + status: 200, + }); + } else if (payload.event.type === "EXPIRATION") { + await manageSubscriptionStatusChange(payload.event.app_user_id, false); + return new Response("ok", { + status: 200, + }); + } else { + return new Response("ok", { + status: 200, + }); + } + } catch (err) { + log("error", "An unexpected error occured", { "error": err.toString() }); + return new Response( + "An unexpected error occured", + { + status: 500, + }, + ); + } +}); diff --git a/supabase/migrations/20230922191752_add_profile_fields_for_revenuecat.sql b/supabase/migrations/20230922191752_add_profile_fields_for_revenuecat.sql new file mode 100644 index 0000000..a212cb2 --- /dev/null +++ b/supabase/migrations/20230922191752_add_profile_fields_for_revenuecat.sql @@ -0,0 +1,7 @@ +------------------------------------------------------------------------------------------------------------------------ +-- Add a new "subscriptionProvider" column to the "profiles" table, which is used to store the provider for a users +-- subscription. When a user is on the "free" tier, this column will be NULL. If the user is on the "premium" tier, this +-- column can be "stripe" or "revenuecat". +------------------------------------------------------------------------------------------------------------------------ +ALTER TABLE "profiles" +ADD COLUMN "subscriptionProvider" VARCHAR(255) DEFAULT NULL;