diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index bd5fedf526..28f0a2f85f 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -95,7 +95,8 @@ class AppFlowyCloudDeepLink { } if (_isPaymentSuccessUri(uri)) { - return getIt().onPaymentSuccess(); + final plan = uri.queryParameters['plan']; + return getIt().onPaymentSuccess(plan); } return _isAuthCallbackDeepLink(uri).fold( diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index d6149e2111..f4896087b3 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; @@ -10,7 +11,10 @@ import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; abstract class IUserBackendService { - Future> cancelSubscription(String workspaceId); + Future> cancelSubscription( + String workspaceId, + SubscriptionPlanPB plan, + ); Future> createSubscription( String workspaceId, SubscriptionPlanPB plan, @@ -228,9 +232,16 @@ class UserBackendService implements IUserBackendService { return UserEventLeaveWorkspace(data).send(); } - static Future> - getWorkspaceSubscriptions() { - return UserEventGetWorkspaceSubscriptions().send(); + static Future> + getWorkspaceSubscriptionInfo(String workspaceId) { + final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventGetWorkspaceSubscriptionInfo(params).send(); + } + + static Future> + invalidateWorkspaceSubscriptionCache(String workspaceId) { + final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventInvalidateWorkspaceSubscriptionInfoCache(params).send(); } Future> @@ -250,15 +261,19 @@ class UserBackendService implements IUserBackendService { ..recurringInterval = RecurringIntervalPB.Year ..workspaceSubscriptionPlan = plan ..successUrl = - '${getIt().appflowyCloudConfig.base_url}/web/payment-success'; + '${getIt().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}'; return UserEventSubscribeWorkspace(request).send(); } @override Future> cancelSubscription( String workspaceId, + SubscriptionPlanPB plan, ) { - final request = UserWorkspaceIdPB()..workspaceId = workspaceId; + final request = CancelWorkspaceSubscriptionPB() + ..workspaceId = workspaceId + ..plan = plan; + return UserEventCancelWorkspaceSubscription(request).send(); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart index 0a05b9c73a..fdc3281cc5 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart @@ -3,18 +3,19 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; -import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_billing_bloc.freezed.dart'; @@ -23,8 +24,12 @@ class SettingsBillingBloc extends Bloc { SettingsBillingBloc({ required this.workspaceId, + required Int64 userId, }) : super(const _Initial()) { + _userService = UserBackendService(userId: userId); _service = WorkspaceService(workspaceId: workspaceId); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); on((event, emit) async { await event.when( @@ -33,31 +38,19 @@ class SettingsBillingBloc FlowyError? error; - final subscription = - (await UserBackendService.getWorkspaceSubscriptions()).fold( - (s) => - s.items.firstWhereOrNull((i) => i.workspaceId == workspaceId) ?? - WorkspaceSubscriptionPB( - workspaceId: workspaceId, - subscriptionPlan: SubscriptionPlanPB.None, - isActive: true, - ), + final subscriptionInfo = + (await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + )) + .fold( + (s) => s, (e) { - // Not a Customer yet - if (e.code == ErrorCode.InvalidParams) { - return WorkspaceSubscriptionPB( - workspaceId: workspaceId, - subscriptionPlan: SubscriptionPlanPB.None, - isActive: true, - ); - } - error = e; return null; }, ); - if (subscription == null || error != null) { + if (subscriptionInfo == null || error != null) { return emit(SettingsBillingState.error(error: error)); } @@ -66,6 +59,8 @@ class SettingsBillingBloc unawaited( _billingPortalCompleter.future.then( (result) { + if (isClosed) return; + result.fold( (portal) { _billingPortal = portal; @@ -84,22 +79,20 @@ class SettingsBillingBloc emit( SettingsBillingState.ready( - subscription: subscription, + subscriptionInfo: subscriptionInfo, billingPortal: _billingPortal, ), ); }, - billingPortalFetched: (billingPortal) { - state.maybeWhen( - orElse: () {}, - ready: (subscription, _) => emit( - SettingsBillingState.ready( - subscription: subscription, - billingPortal: billingPortal, - ), + billingPortalFetched: (billingPortal) async => state.maybeWhen( + orElse: () {}, + ready: (subscriptionInfo, _) => emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: billingPortal, ), - ); - }, + ), + ), openCustomerPortal: () async { if (_billingPortalCompleter.isCompleted && _billingPortal != null) { await afLaunchUrlString(_billingPortal!.url); @@ -109,21 +102,58 @@ class SettingsBillingBloc await afLaunchUrlString(_billingPortal!.url); } }, + addSubscription: (plan) async { + final result = + await _userService.createSubscription(workspaceId, plan); + result.fold( + (link) => afLaunchUrlString(link.paymentLink), + (f) => Log.error(f.msg, f), + ); + }, + cancelSubscription: (plan) async { + await _userService.cancelSubscription(workspaceId, plan); + await _onPaymentSuccessful(); + }, + paymentSuccessful: () async { + final result = await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + + final subscriptionInfo = result.toNullable(); + + if (subscriptionInfo != null) { + emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: _billingPortal, + ), + ); + } + }, ); }); } late final String workspaceId; late final WorkspaceService _service; + late final UserBackendService _userService; final _billingPortalCompleter = Completer>(); BillingPortalPB? _billingPortal; + late final SubscriptionSuccessListenable _successListenable; Future _fetchBillingPortal() async { final billingPortalResult = await _service.getBillingPortal(); _billingPortalCompleter.complete(billingPortalResult); } + + Future _onPaymentSuccessful() async { + // Invalidate cache for this workspace + await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId); + + add(const SettingsBillingEvent.paymentSuccessful()); + } } @freezed @@ -133,6 +163,12 @@ class SettingsBillingEvent with _$SettingsBillingEvent { required BillingPortalPB billingPortal, }) = _BillingPortalFetched; const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal; + const factory SettingsBillingEvent.addSubscription(SubscriptionPlanPB plan) = + _AddSubscription; + const factory SettingsBillingEvent.cancelSubscription( + SubscriptionPlanPB plan, + ) = _CancelSubscription; + const factory SettingsBillingEvent.paymentSuccessful() = _PaymentSuccessful; } @freezed @@ -148,7 +184,7 @@ class SettingsBillingState extends Equatable with _$SettingsBillingState { }) = _Error; const factory SettingsBillingState.ready({ - required WorkspaceSubscriptionPB subscription, + required WorkspaceSubscriptionInfoPB subscriptionInfo, required BillingPortalPB? billingPortal, }) = _Ready; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart index 1ec9ed3e77..0ef62f0853 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -11,7 +11,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:bloc/bloc.dart'; -import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -34,7 +33,7 @@ class SettingsPlanBloc extends Bloc { final snapshots = await Future.wait([ _service.getWorkspaceUsage(), - UserBackendService.getWorkspaceSubscriptions(), + UserBackendService.getWorkspaceSubscriptionInfo(workspaceId), ]); FlowyError? error; @@ -47,30 +46,24 @@ class SettingsPlanBloc extends Bloc { }, ); - final subscription = snapshots[1].fold( - (s) => - (s as RepeatedWorkspaceSubscriptionPB) - .items - .firstWhereOrNull((i) => i.workspaceId == workspaceId) ?? - WorkspaceSubscriptionPB( - workspaceId: workspaceId, - subscriptionPlan: SubscriptionPlanPB.None, - isActive: true, - ), + final subscriptionInfo = snapshots[1].fold( + (s) => s as WorkspaceSubscriptionInfoPB, (f) { error = f; return null; }, ); - if (usageResult == null || subscription == null || error != null) { + if (usageResult == null || + subscriptionInfo == null || + error != null) { return emit(SettingsPlanState.error(error: error)); } emit( SettingsPlanState.ready( workspaceUsage: usageResult, - subscription: subscription, + subscriptionInfo: subscriptionInfo, showSuccessDialog: withShowSuccessful, ), ); @@ -79,7 +72,7 @@ class SettingsPlanBloc extends Bloc { emit( SettingsPlanState.ready( workspaceUsage: usageResult, - subscription: subscription, + subscriptionInfo: subscriptionInfo, ), ); } @@ -100,7 +93,14 @@ class SettingsPlanBloc extends Bloc { .mapOrNull(ready: (state) => state) ?.copyWith(downgradeProcessing: true); emit(newState ?? state); - await _userService.cancelSubscription(workspaceId); + + // We can hardcode the subscription plan here because we cannot cancel addons + // on the Plan page + await _userService.cancelSubscription( + workspaceId, + SubscriptionPlanPB.Pro, + ); + add(const SettingsPlanEvent.started()); }, paymentSuccessful: () { @@ -120,7 +120,10 @@ class SettingsPlanBloc extends Bloc { late final IUserBackendService _userService; late final SubscriptionSuccessListenable _successListenable; - void _onPaymentSuccessful() { + Future _onPaymentSuccessful() async { + // Invalidate cache for this workspace + await UserBackendService.invalidateWorkspaceSubscriptionCache(workspaceId); + add(const SettingsPlanEvent.paymentSuccessful()); } @@ -136,9 +139,12 @@ class SettingsPlanEvent with _$SettingsPlanEvent { const factory SettingsPlanEvent.started({ @Default(false) bool withShowSuccessful, }) = _Started; + const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; + const factory SettingsPlanEvent.cancelSubscription() = _CancelSubscription; + const factory SettingsPlanEvent.paymentSuccessful() = _PaymentSuccessful; } @@ -154,7 +160,7 @@ class SettingsPlanState with _$SettingsPlanState { const factory SettingsPlanState.ready({ required WorkspaceUsagePB workspaceUsage, - required WorkspaceSubscriptionPB subscription, + required WorkspaceSubscriptionInfoPB subscriptionInfo, @Default(false) bool showSuccessDialog, @Default(false) bool downgradeProcessing, }) = _Ready; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart index d6dde9e9c1..8d82ec7c01 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart @@ -1,26 +1,52 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:easy_localization/easy_localization.dart'; -extension SubscriptionLabels on WorkspaceSubscriptionPB { - String get label => switch (subscriptionPlan) { - SubscriptionPlanPB.None => +extension SubscriptionLabels on WorkspaceSubscriptionInfoPB { + String get label => switch (plan) { + WorkspacePlanPB.FreePlan => LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), - SubscriptionPlanPB.Pro => + WorkspacePlanPB.ProPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), - SubscriptionPlanPB.Team => + WorkspacePlanPB.TeamPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), _ => 'N/A', }; - String get info => switch (subscriptionPlan) { - SubscriptionPlanPB.None => + String get info => switch (plan) { + WorkspacePlanPB.FreePlan => LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(), - SubscriptionPlanPB.Pro => + WorkspacePlanPB.ProPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(), - SubscriptionPlanPB.Team => + WorkspacePlanPB.TeamPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), _ => 'N/A', }; } + +extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB { + bool get isCanceled => + planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled; +} + +extension WorkspaceAddonsExt on WorkspaceSubscriptionInfoPB { + bool get hasAIMax => + addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiMax); + + bool get hasAIOnDevice => + addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiLocal); +} + +/// These have to match [SubscriptionSuccessListenable.subscribedPlan] labels +extension ToRecognizable on SubscriptionPlanPB { + String? toRecognizable() => switch (this) { + SubscriptionPlanPB.None => 'free', + SubscriptionPlanPB.Pro => 'pro', + SubscriptionPlanPB.Team => 'team', + SubscriptionPlanPB.AiMax => 'ai_max', + SubscriptionPlanPB.AiLocal => 'ai_local', + _ => null, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart index 3dcd87ca58..3dd059da0c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart @@ -7,7 +7,7 @@ final _storageNumberFormat = NumberFormat() extension PresentableUsage on WorkspaceUsagePB { String get totalBlobInGb => - (totalBlobBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString(); + (storageBytesLimit.toInt() / 1024 / 1024 / 1024).round().toString(); /// We use [NumberFormat] to format the current blob in GB. /// @@ -16,5 +16,5 @@ extension PresentableUsage on WorkspaceUsagePB { /// And [NumberFormat.minimumFractionDigits] is set to 0. /// String get currentBlobInGb => - _storageNumberFormat.format(totalBlobBytes.toInt() / 1024 / 1024 / 1024); + _storageNumberFormat.format(storageBytes.toInt() / 1024 / 1024 / 1024); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart b/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart index b53c8237a6..d2b8f141cd 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart @@ -1,7 +1,23 @@ import 'package:flutter/foundation.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + class SubscriptionSuccessListenable extends ChangeNotifier { SubscriptionSuccessListenable(); - void onPaymentSuccess() => notifyListeners(); + String? _plan; + + SubscriptionPlanPB? get subscribedPlan => switch (_plan) { + 'free' => SubscriptionPlanPB.None, + 'pro' => SubscriptionPlanPB.Pro, + 'team' => SubscriptionPlanPB.Team, + 'ai_max' => SubscriptionPlanPB.AiMax, + 'ai_local' => SubscriptionPlanPB.AiLocal, + _ => null, + }; + + void onPaymentSuccess(String? plan) { + _plan = plan; + notifyListeners(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart index 00894c2b21..16ed9d97cc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; @@ -9,6 +8,7 @@ import 'package:appflowy/workspace/presentation/settings/shared/settings_body.da import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; @@ -16,7 +16,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../generated/locale_keys.g.dart'; -const _buttonsMinWidth = 116.0; +const _buttonsMinWidth = 100.0; class SettingsBillingView extends StatelessWidget { const SettingsBillingView({ @@ -31,8 +31,10 @@ class SettingsBillingView extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => SettingsBillingBloc(workspaceId: workspaceId) - ..add(const SettingsBillingEvent.started()), + create: (_) => SettingsBillingBloc( + workspaceId: workspaceId, + userId: user.id, + )..add(const SettingsBillingEvent.started()), child: BlocBuilder( builder: (context, state) { return state.map( @@ -59,8 +61,7 @@ class SettingsBillingView extends StatelessWidget { }, ready: (state) { final billingPortalEnabled = - state.subscription.subscriptionPlan != - SubscriptionPlanPB.None; + state.subscriptionInfo.plan != WorkspacePlanPB.FreePlan; return SettingsBody( title: LocaleKeys.settings_billingPage_title.tr(), @@ -73,10 +74,10 @@ class SettingsBillingView extends StatelessWidget { context, workspaceId, user.id, - state.subscription, + state.subscriptionInfo, ), fontWeight: FontWeight.w500, - label: state.subscription.label, + label: state.subscriptionInfo.label, buttonLabel: LocaleKeys .settings_billingPage_plan_planButtonLabel .tr(), @@ -127,33 +128,31 @@ class SettingsBillingView extends StatelessWidget { SettingsCategory( title: LocaleKeys.settings_billingPage_addons_title.tr(), children: [ - SingleSettingAction( - buttonType: SingleSettingsButtonType.highlight, + _AITile( + plan: SubscriptionPlanPB.AiMax, label: LocaleKeys .settings_billingPage_addons_aiMax_label .tr(), description: LocaleKeys .settings_billingPage_addons_aiMax_description .tr(), - buttonLabel: LocaleKeys - .settings_billingPage_addons_aiMax_buttonLabel - .tr(), - fontWeight: FontWeight.w500, - minWidth: _buttonsMinWidth, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiMax, + ), ), - SingleSettingAction( - buttonType: SingleSettingsButtonType.highlight, + _AITile( + plan: SubscriptionPlanPB.AiLocal, label: LocaleKeys .settings_billingPage_addons_aiOnDevice_label .tr(), description: LocaleKeys .settings_billingPage_addons_aiOnDevice_description .tr(), - buttonLabel: LocaleKeys - .settings_billingPage_addons_aiOnDevice_buttonLabel - .tr(), - fontWeight: FontWeight.w500, - minWidth: _buttonsMinWidth, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, + ), ), ], ), @@ -170,7 +169,7 @@ class SettingsBillingView extends StatelessWidget { BuildContext context, String workspaceId, Int64 userId, - WorkspaceSubscriptionPB subscription, + WorkspaceSubscriptionInfoPB subscriptionInfo, ) => showDialog( context: context, @@ -180,7 +179,7 @@ class SettingsBillingView extends StatelessWidget { ..add(const SettingsPlanEvent.started()), child: SettingsPlanComparisonDialog( workspaceId: workspaceId, - subscription: subscription, + subscriptionInfo: subscriptionInfo, ), ), ).then((didChangePlan) { @@ -191,3 +190,48 @@ class SettingsBillingView extends StatelessWidget { } }); } + +class _AITile extends StatelessWidget { + const _AITile({ + required this.label, + required this.description, + required this.plan, + this.subscriptionInfo, + }); + + final String label; + final String description; + final SubscriptionPlanPB plan; + final WorkspaceAddOnPB? subscriptionInfo; + + @override + Widget build(BuildContext context) { + final isCanceled = subscriptionInfo?.addOnSubscription.status == + WorkspaceSubscriptionStatusPB.Canceled; + + return SingleSettingAction( + label: label, + description: description, + buttonLabel: subscriptionInfo != null + ? isCanceled + ? LocaleKeys.settings_billingPage_addons_renewLabel.tr() + : LocaleKeys.settings_billingPage_addons_removeLabel.tr() + : LocaleKeys.settings_billingPage_addons_addLabel.tr(), + fontWeight: FontWeight.w500, + minWidth: _buttonsMinWidth, + onPressed: () { + if (subscriptionInfo != null && !isCanceled) { + // Cancel the addon + context + .read() + .add(SettingsBillingEvent.cancelSubscription(plan)); + } else { + // Add/renew the addon + context + .read() + .add(SettingsBillingEvent.addSubscription(plan)); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart index 1c04fe37e9..bf5242ffff 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -1,28 +1,29 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../generated/locale_keys.g.dart'; + class SettingsPlanComparisonDialog extends StatefulWidget { const SettingsPlanComparisonDialog({ super.key, required this.workspaceId, - required this.subscription, + required this.subscriptionInfo, }); final String workspaceId; - final WorkspaceSubscriptionPB subscription; + final WorkspaceSubscriptionInfoPB subscriptionInfo; @override State createState() => @@ -34,7 +35,7 @@ class _SettingsPlanComparisonDialogState final horizontalController = ScrollController(); final verticalController = ScrollController(); - late WorkspaceSubscriptionPB currentSubscription = widget.subscription; + late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo; @override void dispose() { @@ -58,19 +59,17 @@ class _SettingsPlanComparisonDialogState if (readyState.showSuccessDialog) { SettingsAlertDialog( title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title - .tr(args: [readyState.subscription.label]), + .tr(args: [readyState.subscriptionInfo.label]), subtitle: LocaleKeys .settings_comparePlanDialog_paymentSuccess_description - .tr(args: [readyState.subscription.label]), + .tr(args: [readyState.subscriptionInfo.label]), hideCancelButton: true, confirm: Navigator.of(context).pop, confirmLabel: LocaleKeys.button_close.tr(), ).show(context); } - setState(() { - currentSubscription = readyState.subscription; - }); + setState(() => currentInfo = readyState.subscriptionInfo); }, builder: (context, state) => FlowyDialog( constraints: const BoxConstraints(maxWidth: 784, minWidth: 674), @@ -90,8 +89,7 @@ class _SettingsPlanComparisonDialogState const Spacer(), GestureDetector( onTap: () => Navigator.of(context).pop( - currentSubscription.subscriptionPlan != - widget.subscription.subscriptionPlan, + currentInfo.plan != widget.subscriptionInfo.plan, ), child: MouseRegion( cursor: SystemMouseCursors.click, @@ -170,32 +168,30 @@ class _SettingsPlanComparisonDialogState .settings_comparePlanDialog_freePlan_priceInfo .tr(), cells: _freeLabels, - isCurrent: currentSubscription.subscriptionPlan == - SubscriptionPlanPB.None, + isCurrent: + currentInfo.plan == WorkspacePlanPB.FreePlan, canDowngrade: - currentSubscription.subscriptionPlan != - SubscriptionPlanPB.None, - currentCanceled: currentSubscription.hasCanceled || + currentInfo.plan != WorkspacePlanPB.FreePlan, + currentCanceled: currentInfo.isCanceled || (context .watch() .state .mapOrNull( loading: (_) => true, - ready: (state) => - state.downgradeProcessing, + ready: (s) => s.downgradeProcessing, ) ?? false), onSelected: () async { - if (currentSubscription.subscriptionPlan == - SubscriptionPlanPB.None || - currentSubscription.hasCanceled) { + if (currentInfo.plan == + WorkspacePlanPB.FreePlan || + currentInfo.isCanceled) { return; } await SettingsAlertDialog( title: LocaleKeys .settings_comparePlanDialog_downgradeDialog_title - .tr(args: [currentSubscription.label]), + .tr(args: [currentInfo.label]), subtitle: LocaleKeys .settings_comparePlanDialog_downgradeDialog_description .tr(), @@ -228,11 +224,11 @@ class _SettingsPlanComparisonDialogState .settings_comparePlanDialog_proPlan_priceInfo .tr(), cells: _proLabels, - isCurrent: currentSubscription.subscriptionPlan == - SubscriptionPlanPB.Pro, - canUpgrade: currentSubscription.subscriptionPlan == - SubscriptionPlanPB.None, - currentCanceled: currentSubscription.hasCanceled, + isCurrent: + currentInfo.plan == WorkspacePlanPB.ProPlan, + canUpgrade: + currentInfo.plan == WorkspacePlanPB.FreePlan, + currentCanceled: currentInfo.isCanceled, onSelected: () => context.read().add( const SettingsPlanEvent.addSubscription( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart index 3ed26db3b0..e547c6774a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -68,40 +68,74 @@ class SettingsPlanView extends StatelessWidget { children: [ _PlanUsageSummary( usage: state.workspaceUsage, - subscription: state.subscription, + subscriptionInfo: state.subscriptionInfo, ), const VSpace(16), - _CurrentPlanBox(subscription: state.subscription), + _CurrentPlanBox(subscriptionInfo: state.subscriptionInfo), const VSpace(16), // TODO(Mathias): Localize and add business logic FlowyText( - 'Add-ons', + LocaleKeys.settings_planPage_planUsage_addons_title.tr(), fontSize: 18, color: AFThemeExtension.of(context).strongText, fontWeight: FontWeight.w600, ), const VSpace(8), - const Row( + Row( children: [ Flexible( child: _AddOnBox( - title: "AI Max", - description: - "Unlimited AI models and access to advanced models", - price: "US\$8", - priceInfo: "billed annually or \$10 billed monthly", - buttonText: "Add AI Max", + title: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_price + .tr(args: ['\$8']), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_priceInfo + .tr(), + billingInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_billingInfo + .tr(args: ['\$10']), + buttonText: state.subscriptionInfo.hasAIMax + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIMax, ), ), - HSpace(8), + const HSpace(8), Flexible( child: _AddOnBox( - title: "AI Offline", - description: - "Local AI on your own hardware for ultimate privacy", - price: "US\$8", - priceInfo: "billed annually or \$10 billed monthly", - buttonText: "Add AI Offline", + title: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_price + .tr(args: ['\$8']), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_priceInfo + .tr(), + billingInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_billingInfo + .tr(args: ['\$10']), + buttonText: state.subscriptionInfo.hasAIOnDevice + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIOnDevice, ), ), ], @@ -116,9 +150,9 @@ class SettingsPlanView extends StatelessWidget { } class _CurrentPlanBox extends StatefulWidget { - const _CurrentPlanBox({required this.subscription}); + const _CurrentPlanBox({required this.subscriptionInfo}); - final WorkspaceSubscriptionPB subscription; + final WorkspaceSubscriptionInfoPB subscriptionInfo; @override State<_CurrentPlanBox> createState() => _CurrentPlanBoxState(); @@ -161,13 +195,13 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { children: [ const VSpace(4), FlowyText.semibold( - widget.subscription.label, + widget.subscriptionInfo.label, fontSize: 24, color: AFThemeExtension.of(context).strongText, ), const VSpace(8), FlowyText.regular( - widget.subscription.info, + widget.subscriptionInfo.info, fontSize: 16, color: AFThemeExtension.of(context).strongText, maxLines: 3, @@ -189,7 +223,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { onPressed: () => _openPricingDialog( context, context.read().workspaceId, - widget.subscription, + widget.subscriptionInfo, ), ), ), @@ -198,7 +232,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { ), ], ), - if (widget.subscription.hasCanceled) ...[ + if (widget.subscriptionInfo.isCanceled) ...[ const VSpace(12), FlowyText( LocaleKeys @@ -245,7 +279,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { String _canceledDate(BuildContext context) { final appearance = context.read().state; return appearance.dateFormat.formatDate( - widget.subscription.canceledAt.toDateTime(), + widget.subscriptionInfo.planSubscription.endDate.toDateTime(), false, ); } @@ -253,7 +287,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { void _openPricingDialog( BuildContext context, String workspaceId, - WorkspaceSubscriptionPB subscription, + WorkspaceSubscriptionInfoPB subscriptionInfo, ) => showDialog( context: context, @@ -261,7 +295,7 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { value: planBloc, child: SettingsPlanComparisonDialog( workspaceId: workspaceId, - subscription: subscription, + subscriptionInfo: subscriptionInfo, ), ), ); @@ -270,11 +304,11 @@ class _CurrentPlanBoxState extends State<_CurrentPlanBox> { class _PlanUsageSummary extends StatelessWidget { const _PlanUsageSummary({ required this.usage, - required this.subscription, + required this.subscriptionInfo, }); final WorkspaceUsagePB usage; - final WorkspaceSubscriptionPB subscription; + final WorkspaceSubscriptionInfoPB subscriptionInfo; @override Widget build(BuildContext context) { @@ -294,8 +328,8 @@ class _PlanUsageSummary extends StatelessWidget { Expanded( child: _UsageBox( title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(), - replacementText: subscription.subscriptionPlan == - SubscriptionPlanPB.Pro + replacementText: subscriptionInfo.plan == + WorkspacePlanPB.ProPlan ? LocaleKeys.settings_planPage_planUsage_storageUnlimited .tr() : null, @@ -305,8 +339,8 @@ class _PlanUsageSummary extends StatelessWidget { usage.totalBlobInGb, ], ), - value: usage.totalBlobBytes.toInt() / - usage.totalBlobBytesLimit.toInt(), + value: usage.storageBytes.toInt() / + usage.storageBytesLimit.toInt(), ), ), Expanded( @@ -330,12 +364,11 @@ class _PlanUsageSummary extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (subscription.subscriptionPlan == SubscriptionPlanPB.None) ...[ + if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[ _ToggleMore( value: false, label: LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), - subscription: subscription, badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(), onTap: () async { @@ -407,14 +440,12 @@ class _ToggleMore extends StatefulWidget { const _ToggleMore({ required this.value, required this.label, - required this.subscription, this.badgeLabel, this.onTap, }); final bool value; final String label; - final WorkspaceSubscriptionPB subscription; final String? badgeLabel; final Future Function()? onTap; @@ -535,14 +566,18 @@ class _AddOnBox extends StatelessWidget { required this.description, required this.price, required this.priceInfo, + required this.billingInfo, required this.buttonText, + required this.isActive, }); final String title; final String description; final String price; final String priceInfo; + final String billingInfo; final String buttonText; + final bool isActive; @override Widget build(BuildContext context) { @@ -553,7 +588,12 @@ class _AddOnBox extends StatelessWidget { vertical: 12, ), decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFBDBDBD)), + border: Border.all( + color: isActive ? const Color(0xFF9C00FB) : const Color(0xFFBDBDBD), + ), + color: isActive + ? const Color(0xFFF7F8FC).withOpacity(0.05) + : Colors.transparent, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -578,7 +618,7 @@ class _AddOnBox extends StatelessWidget { color: AFThemeExtension.of(context).strongText, ), FlowyText( - '/user per month', + priceInfo, fontSize: 11, color: AFThemeExtension.of(context).strongText, ), @@ -587,7 +627,7 @@ class _AddOnBox extends StatelessWidget { children: [ Expanded( child: FlowyText( - priceInfo, + billingInfo, color: AFThemeExtension.of(context).secondaryTextColor, fontSize: 11, maxLines: 2, @@ -598,19 +638,28 @@ class _AddOnBox extends StatelessWidget { const Spacer(), Row( children: [ - FlowyTextButton( - buttonText, - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 7), - fillColor: Colors.transparent, - constraints: const BoxConstraints(minWidth: 115), - radius: Corners.s16Border, - hoverColor: const Color(0xFF5C3699), - fontColor: const Color(0xFF5C3699), - fontHoverColor: Colors.white, - borderColor: const Color(0xFF5C3699), - fontSize: 12, - onPressed: () {}, + Expanded( + child: FlowyTextButton( + buttonText, + mainAxisAlignment: MainAxisAlignment.center, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + fillColor: + isActive ? const Color(0xFFE8E2EE) : Colors.transparent, + constraints: const BoxConstraints(minWidth: 115), + radius: Corners.s16Border, + hoverColor: isActive + ? const Color(0xFFE8E2EE) + : const Color(0xFF5C3699), + fontColor: const Color(0xFF5C3699), + fontHoverColor: + isActive ? const Color(0xFF5C3699) : Colors.white, + borderColor: isActive + ? const Color(0xFFE8E2EE) + : const Color(0xFF5C3699), + fontSize: 12, + onPressed: isActive ? null : () {}, + ), ), ], ), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index 2849de77a4..a548a1a9be 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -213,7 +213,7 @@ class FlowyTextButton extends StatelessWidget { child = ConstrainedBox( constraints: constraints, child: TextButton( - onPressed: onPressed ?? () {}, + onPressed: onPressed, focusNode: FocusNode(skipTraversal: onPressed == null), style: ButtonStyle( overlayColor: const WidgetStatePropertyAll(Colors.transparent), diff --git a/frontend/resources/flowy_icons/16x/check_circle_outlined.svg b/frontend/resources/flowy_icons/16x/check_circle_outlined.svg new file mode 100644 index 0000000000..3677adcc96 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/check_circle_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index bc3558a57f..dcf4a0718b 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -666,6 +666,25 @@ "upgrade": "Change plan", "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." }, + "addons": { + "title": "Add-ons", + "addLabel": "Add", + "activeLabel": "Added", + "aiMax": { + "title": "AI Max", + "description": "Unlock unlimited AI", + "price": "US{}", + "priceInfo": "/user per month", + "billingInfo": "billed annually or US{} biled monthly" + }, + "aiOnDevice": { + "title": "AI On-device", + "description": "AI offline on your device", + "price": "US{}", + "priceInfo": "/user per month", + "billingInfo": "billed annually or US{} biled monthly" + } + }, "deal": { "bannerLabel": "New year deal!", "title": "Grow your team!", @@ -692,15 +711,16 @@ }, "addons": { "title": "Add-ons", + "addLabel": "Add", + "removeLabel": "Remove", + "renewLabel": "Renew", "aiMax": { "label": "AI Max", - "description": "US$8 /user per month billed annually or US$10 billed monthly", - "buttonLabel": "Add AI Max" + "description": "US$8 /user per month billed annually or US$10 billed monthly" }, "aiOnDevice": { "label": "AI On-device", - "description": "US$8 /user per month billed annually or US$10 billed monthly", - "buttonLabel": "Add AI On-device" + "description": "US$8 /user per month billed annually or US$10 billed monthly" } } }, @@ -2033,7 +2053,6 @@ "upgrade": "Update", "upgradeYourSpace": "Create multiple Spaces", "quicklySwitch": "Quickly switch to the next space", - "duplicate": "Duplicate Space", "movePageToSpace": "Move page to space", "switchSpace": "Switch space" diff --git a/frontend/rust-lib/event-integration-test/src/user_event.rs b/frontend/rust-lib/event-integration-test/src/user_event.rs index 12f7f9744c..40e8044806 100644 --- a/frontend/rust-lib/event-integration-test/src/user_event.rs +++ b/frontend/rust-lib/event-integration-test/src/user_event.rs @@ -19,9 +19,9 @@ use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, - OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, RepeatedWorkspaceSubscriptionPB, - SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, - UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, + OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, + SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, + UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; @@ -315,14 +315,6 @@ impl EventIntegrationTest { .await; } - pub async fn get_workspace_subscriptions(&self) -> RepeatedWorkspaceSubscriptionPB { - EventBuilder::new(self.clone()) - .event(UserEvent::GetWorkspaceSubscriptions) - .async_send() - .await - .parse::() - } - pub async fn leave_workspace(&self, workspace_id: &str) { let payload = UserWorkspaceIdPB { workspace_id: workspace_id.to_string(), diff --git a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs index 72abe73fcb..b72ceba33f 100644 --- a/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs @@ -240,18 +240,3 @@ async fn af_cloud_different_open_same_workspace_test() { assert_eq!(views.len(), 1, "only get: {:?}", views); // Expecting two views. assert_eq!(views[0].name, "Getting started"); } - -#[tokio::test] -async fn af_cloud_get_workspace_subscriptions_test() { - user_localhost_af_cloud().await; - - let test = EventIntegrationTest::new().await; - - let workspaces = test.get_all_workspaces().await.items; - let first_workspace_id = workspaces[0].workspace_id.as_str(); - assert_eq!(workspaces.len(), 1); - - let subscriptions = test.get_workspace_subscriptions().await; - assert_eq!(subscriptions.items.len(), 1); - assert_eq!(subscriptions.items[0].workspace_id, first_workspace_id); -} diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-07-08-135546_workspace_subscriptions/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-07-08-135546_workspace_subscriptions/up.sql index adcbef8e7b..e389245d02 100644 --- a/frontend/rust-lib/flowy-sqlite/migrations/2024-07-08-135546_workspace_subscriptions/up.sql +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-07-08-135546_workspace_subscriptions/up.sql @@ -2,10 +2,9 @@ CREATE TABLE workspace_subscriptions_table ( workspace_id TEXT NOT NULL, subscription_plan INTEGER NOT NULL, - recurring_interval INTEGER NOT NULL, - is_active BOOLEAN NOT NULL, - has_canceled BOOLEAN NOT NULL DEFAULT FALSE, - canceled_at TIMESTAMP, + workspace_status INTEGER NOT NULL, + end_date TIMESTAMP, + addons TEXT NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (workspace_id) ); \ No newline at end of file diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index c8cdc3cd62..b62df6251f 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -118,10 +118,9 @@ diesel::table! { workspace_subscriptions_table (workspace_id) { workspace_id -> Text, subscription_plan -> BigInt, - recurring_interval -> BigInt, - is_active -> Bool, - has_canceled -> Bool, - canceled_at -> Nullable, + workspace_status -> BigInt, + end_date -> BigInt, + addons -> Text, updated_at -> Timestamp, } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index 3a32cf474a..91a3f64784 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -1,6 +1,8 @@ +use chrono::Utc; use client_api::entity::billing_dto::{ RecurringInterval, SubscriptionPlan, SubscriptionStatus, WorkspaceSubscriptionStatus, }; +use serde::{Deserialize, Serialize}; use std::str::FromStr; use validator::Validate; @@ -9,6 +11,8 @@ use flowy_user_pub::cloud::{AFWorkspaceSettings, AFWorkspaceSettingsChange}; use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember, WorkspaceSubscription}; use lib_infra::validator_fn::required_not_empty_str; +use crate::services::sqlite_sql::workspace_sql::WorkspaceSubscriptionsTable; + #[derive(ProtoBuf, Default, Clone)] pub struct WorkspaceMemberPB { #[pb(index = 1)] @@ -265,17 +269,28 @@ impl From for RecurringIntervalPB { } } -#[derive(ProtoBuf_Enum, Clone, Default, Debug)] +#[derive(ProtoBuf_Enum, Clone, Default, Debug, Serialize, Deserialize)] pub enum SubscriptionPlanPB { #[default] None = 0, Pro = 1, Team = 2, + // Add-ons AiMax = 3, AiLocal = 4, } +impl From for SubscriptionPlanPB { + fn from(value: WorkspacePlanPB) -> Self { + match value { + WorkspacePlanPB::FreePlan => SubscriptionPlanPB::None, + WorkspacePlanPB::ProPlan => SubscriptionPlanPB::Pro, + WorkspacePlanPB::TeamPlan => SubscriptionPlanPB::Team, + } + } +} + impl From for SubscriptionPlan { fn from(value: SubscriptionPlanPB) -> Self { match value { @@ -467,30 +482,55 @@ pub struct WorkspaceSubscriptionInfoPB { pub add_ons: Vec, } +impl WorkspaceSubscriptionInfoPB { + pub fn default_from_workspace_id(workspace_id: String) -> Self { + Self { + plan: WorkspacePlanPB::FreePlan, + plan_subscription: WorkspaceSubscriptionV2PB { + workspace_id, + subscription_plan: SubscriptionPlanPB::None, + status: WorkspaceSubscriptionStatusPB::Active, + end_date: 0, + }, + add_ons: Vec::new(), + } + } +} + impl From> for WorkspaceSubscriptionInfoPB { fn from(subs: Vec) -> Self { - let mut plan = WorkspacePlanPB::WorkspacePlanFree; + let mut plan = WorkspacePlanPB::FreePlan; let mut plan_subscription = WorkspaceSubscriptionV2PB::default(); let mut add_ons = Vec::new(); for sub in subs { match sub.workspace_plan { SubscriptionPlan::Free => { - plan = WorkspacePlanPB::WorkspacePlanFree; + plan = WorkspacePlanPB::FreePlan; }, SubscriptionPlan::Pro => { - plan = WorkspacePlanPB::WorkspacePlanPro; + plan = WorkspacePlanPB::ProPlan; plan_subscription = sub.into(); }, SubscriptionPlan::Team => { - plan = WorkspacePlanPB::WorkspacePlanTeam; + plan = WorkspacePlanPB::TeamPlan; }, SubscriptionPlan::AiMax => { + if plan_subscription.workspace_id.is_empty() { + plan_subscription = + WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone()); + } + add_ons.push(WorkspaceAddOnPB { type_: WorkspaceAddOnPBType::AddOnAiMax, add_on_subscription: sub.into(), }); }, SubscriptionPlan::AiLocal => { + if plan_subscription.workspace_id.is_empty() { + plan_subscription = + WorkspaceSubscriptionV2PB::default_with_workspace_id(sub.workspace_id.clone()); + } + add_ons.push(WorkspaceAddOnPB { type_: WorkspaceAddOnPBType::AddOnAiLocal, add_on_subscription: sub.into(), @@ -498,6 +538,7 @@ impl From> for WorkspaceSubscriptionInfoPB { }, } } + WorkspaceSubscriptionInfoPB { plan, plan_subscription, @@ -506,15 +547,45 @@ impl From> for WorkspaceSubscriptionInfoPB { } } +impl From for WorkspaceSubscriptionsTable { + fn from(value: WorkspaceSubscriptionInfoPB) -> Self { + WorkspaceSubscriptionsTable { + workspace_id: value.plan_subscription.workspace_id, + subscription_plan: value.plan.into(), + workspace_status: value.plan_subscription.status.into(), + end_date: value.plan_subscription.end_date, + addons: serde_json::to_string(&value.add_ons).unwrap_or_default(), + updated_at: Utc::now().naive_utc(), + } + } +} + #[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)] pub enum WorkspacePlanPB { #[default] - WorkspacePlanFree = 0, - WorkspacePlanPro = 1, - WorkspacePlanTeam = 2, + FreePlan = 0, + ProPlan = 1, + TeamPlan = 2, } -#[derive(Debug, ProtoBuf, Default, Clone)] +impl Into for WorkspacePlanPB { + fn into(self) -> i64 { + self as i64 + } +} + +impl From for WorkspacePlanPB { + fn from(value: i64) -> Self { + match value { + 0 => WorkspacePlanPB::FreePlan, + 1 => WorkspacePlanPB::ProPlan, + 2 => WorkspacePlanPB::TeamPlan, + _ => WorkspacePlanPB::FreePlan, + } + } +} + +#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)] pub struct WorkspaceAddOnPB { #[pb(index = 1)] type_: WorkspaceAddOnPBType, @@ -522,14 +593,14 @@ pub struct WorkspaceAddOnPB { add_on_subscription: WorkspaceSubscriptionV2PB, } -#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)] +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub enum WorkspaceAddOnPBType { #[default] AddOnAiLocal = 0, AddOnAiMax = 1, } -#[derive(Debug, ProtoBuf, Default, Clone)] +#[derive(Debug, ProtoBuf, Default, Clone, Serialize, Deserialize)] pub struct WorkspaceSubscriptionV2PB { #[pb(index = 1)] pub workspace_id: String, @@ -544,6 +615,17 @@ pub struct WorkspaceSubscriptionV2PB { pub end_date: i64, } +impl WorkspaceSubscriptionV2PB { + pub fn default_with_workspace_id(workspace_id: String) -> Self { + Self { + workspace_id, + subscription_plan: SubscriptionPlanPB::None, + status: WorkspaceSubscriptionStatusPB::Active, + end_date: 0, + } + } +} + impl From for WorkspaceSubscriptionV2PB { fn from(sub: WorkspaceSubscriptionStatus) -> Self { Self { @@ -555,13 +637,28 @@ impl From for WorkspaceSubscriptionV2PB { } } -#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default)] +#[derive(ProtoBuf_Enum, Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub enum WorkspaceSubscriptionStatusPB { #[default] Active = 0, Canceled = 1, } +impl Into for WorkspaceSubscriptionStatusPB { + fn into(self) -> i64 { + self as i64 + } +} + +impl From for WorkspaceSubscriptionStatusPB { + fn from(value: i64) -> Self { + match value { + 0 => WorkspaceSubscriptionStatusPB::Active, + _ => WorkspaceSubscriptionStatusPB::Canceled, + } + } +} + impl From for WorkspaceSubscriptionStatusPB { fn from(status: SubscriptionStatus) -> Self { match status { diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index f3f9a307cb..8883626c40 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -773,20 +773,6 @@ pub async fn subscribe_workspace_handler( data_result_ok(PaymentLinkPB { payment_link }) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub async fn get_workspace_subscriptions_handler( - manager: AFPluginState>, -) -> DataResult { - let manager = upgrade_manager(manager)?; - let subs = manager - .get_workspace_subscriptions() - .await? - .into_iter() - .map(WorkspaceSubscriptionPB::from) - .collect::>(); - data_result_ok(RepeatedWorkspaceSubscriptionPB { items: subs }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_workspace_subscription_info_handler( params: AFPluginData, @@ -843,6 +829,18 @@ pub async fn get_billing_portal_handler( data_result_ok(BillingPortalPB { url }) } +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn invalidate_workspace_subscription_info_cache_handler( + params: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let params = params.try_into_inner().unwrap(); + let manager = upgrade_manager(manager).unwrap(); + manager + .invalidate_workspace_subscription_info_cache(params.workspace_id) + .await +} + #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_workspace_member_info( param: AFPluginData, diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index fb34f6daae..0eb9745beb 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -70,15 +70,14 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler) // Billing .event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler) - .event(UserEvent::GetWorkspaceSubscriptions, get_workspace_subscriptions_handler) - .event(UserEvent::GetWorkspaceSubscriptionInfo, get_workspace_subscription_info_handler) + .event(UserEvent::GetWorkspaceSubscriptionInfo, get_workspace_subscription_info_handler) .event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler) .event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler) .event(UserEvent::GetBillingPortal, get_billing_portal_handler) + .event(UserEvent::InvalidateWorkspaceSubscriptionInfoCache, invalidate_workspace_subscription_info_cache_handler) // Workspace Setting .event(UserEvent::UpdateWorkspaceSetting, update_workspace_setting) .event(UserEvent::GetWorkspaceSetting, get_workspace_setting) - } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -243,10 +242,7 @@ pub enum UserEvent { #[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")] SubscribeWorkspace = 51, - #[event(output = "RepeatedWorkspaceSubscriptionPB")] - GetWorkspaceSubscriptions = 52, - - #[event(input = "UserWorkspaceIdPB")] + #[event(input = "CancelWorkspaceSubscriptionPB")] CancelWorkspaceSubscription = 53, #[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")] @@ -266,6 +262,9 @@ pub enum UserEvent { #[event(input = "UserWorkspaceIdPB", output = "WorkspaceSubscriptionInfoPB")] GetWorkspaceSubscriptionInfo = 59, + + #[event(input = "UserWorkspaceIdPB")] + InvalidateWorkspaceSubscriptionInfoCache = 60, } pub trait UserStatusCallback: Send + Sync + 'static { diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs index 78c2fd8913..012e955f37 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs @@ -1,6 +1,5 @@ use chrono::{TimeZone, Utc}; -use client_api::entity::billing_dto::{RecurringInterval, SubscriptionPlan}; -use diesel::insert_into; +use diesel::{delete, insert_into}; use diesel::{RunQueryDsl, SqliteConnection}; use flowy_error::{FlowyError, FlowyResult}; use flowy_sqlite::schema::user_workspace_table; @@ -8,9 +7,13 @@ use flowy_sqlite::schema::workspace_subscriptions_table; use flowy_sqlite::schema::workspace_subscriptions_table::dsl; use flowy_sqlite::DBConnection; use flowy_sqlite::{query_dsl::*, ExpressionMethods}; -use flowy_user_pub::entities::{UserWorkspace, WorkspaceSubscription}; +use flowy_user_pub::entities::UserWorkspace; use std::convert::TryFrom; +use crate::entities::{ + SubscriptionPlanPB, WorkspacePlanPB, WorkspaceSubscriptionInfoPB, WorkspaceSubscriptionV2PB, +}; + #[derive(Clone, Default, Queryable, Identifiable, Insertable)] #[diesel(table_name = user_workspace_table)] pub struct UserWorkspaceTable { @@ -28,10 +31,9 @@ pub struct UserWorkspaceTable { pub struct WorkspaceSubscriptionsTable { pub workspace_id: String, pub subscription_plan: i64, - pub recurring_interval: i64, - pub is_active: bool, - pub has_canceled: bool, - pub canceled_at: Option, + pub workspace_status: i64, + pub end_date: i64, + pub addons: String, pub updated_at: chrono::NaiveDateTime, } @@ -118,17 +120,33 @@ pub fn upsert_workspace_subscription>( Ok(()) } -impl TryFrom for WorkspaceSubscription { - type Error = FlowyError; - fn try_from(value: WorkspaceSubscriptionsTable) -> Result { - Ok(Self { - workspace_id: value.workspace_id, - subscription_plan: SubscriptionPlan::try_from(value.subscription_plan as i16)?, - recurring_interval: RecurringInterval::try_from(value.recurring_interval as i16)?, - is_active: value.is_active, - has_canceled: value.has_canceled, - canceled_at: value.canceled_at, - }) +pub fn delete_workspace_subscription_from_cache( + mut conn: DBConnection, + workspace_id: &str, +) -> FlowyResult<()> { + let delete = delete( + dsl::workspace_subscriptions_table + .filter(workspace_subscriptions_table::workspace_id.eq(workspace_id)), + ); + + delete.execute(&mut conn)?; + + Ok(()) +} + +impl Into for WorkspaceSubscriptionsTable { + fn into(self) -> WorkspaceSubscriptionInfoPB { + WorkspaceSubscriptionInfoPB { + plan: self.subscription_plan.into(), + plan_subscription: WorkspaceSubscriptionV2PB { + workspace_id: self.workspace_id, + subscription_plan: SubscriptionPlanPB::from(WorkspacePlanPB::from(self.subscription_plan)), + status: self.workspace_status.into(), + end_date: self.end_date, + }, + // Deserialize + add_ons: serde_json::from_str(&self.addons).unwrap_or_default(), + } } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index 58e1d914ca..7a6dc27f48 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -1,7 +1,6 @@ use chrono::{Duration, NaiveDateTime, Utc}; -use client_api::entity::billing_dto::{ - RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus, WorkspaceUsageAndLimit, -}; +use client_api::entity::billing_dto::{SubscriptionPlan, WorkspaceUsageAndLimit}; + use std::convert::TryFrom; use std::sync::Arc; @@ -15,13 +14,13 @@ use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods}; use flowy_user_pub::entities::{ Role, UpdateUserProfileParams, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, - WorkspaceMember, WorkspaceSubscription, + WorkspaceMember, }; use lib_dispatch::prelude::af_spawn; use crate::entities::{ RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, UpdateUserWorkspaceSettingPB, - UseAISettingPB, UserWorkspacePB, + UseAISettingPB, UserWorkspacePB, WorkspaceSubscriptionInfoPB, }; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; @@ -33,9 +32,9 @@ use crate::services::sqlite_sql::member_sql::{ }; use crate::services::sqlite_sql::user_sql::UserTableChangeset; use crate::services::sqlite_sql::workspace_sql::{ - get_all_user_workspace_op, get_user_workspace_op, insert_new_workspaces_op, - select_workspace_subscription, upsert_workspace_subscription, UserWorkspaceTable, - WorkspaceSubscriptionsTable, + delete_workspace_subscription_from_cache, get_all_user_workspace_op, get_user_workspace_op, + insert_new_workspaces_op, select_workspace_subscription, upsert_workspace_subscription, + UserWorkspaceTable, }; use crate::user_manager::{upsert_user_profile_change, UserManager}; use flowy_user_pub::session::Session; @@ -450,75 +449,54 @@ impl UserManager { Ok(payment_link) } - #[instrument(level = "info", skip(self), err)] - pub async fn get_workspace_subscriptions(&self) -> FlowyResult> { - let session = self.get_session()?; - let uid = session.user_id; - let workspace_id = session.user_workspace.id.clone(); - let db = self.authenticate_user.get_sqlite_connection(uid)?; - - // We check if we can use the cache from local sqlite db - if let Ok(subscription) = select_workspace_subscription(db, &workspace_id) { - if is_older_than_n_minutes(subscription.updated_at, 10) { - self.get_workspace_subscriptions_from_remote(uid).await?; - } - - return Ok(vec![WorkspaceSubscription { - workspace_id, - subscription_plan: SubscriptionPlan::try_from(subscription.subscription_plan as i16)?, - recurring_interval: RecurringInterval::try_from(subscription.recurring_interval as i16)?, - is_active: subscription.is_active, - has_canceled: subscription.has_canceled, - canceled_at: subscription.canceled_at, - }]); - } - - let subscriptions = self.get_workspace_subscriptions_from_remote(uid).await?; - - Ok(subscriptions) - } - #[instrument(level = "info", skip(self), err)] pub async fn get_workspace_subscription_info( &self, workspace_id: String, - ) -> FlowyResult> { - self - .cloud_services - .get_user_service()? - .get_workspace_subscriptions() - .await - } + ) -> FlowyResult { + let session = self.get_session()?; + let uid = session.user_id; + let db = self.authenticate_user.get_sqlite_connection(uid)?; - async fn get_workspace_subscriptions_from_remote( - &self, - uid: i64, - ) -> FlowyResult> { - let subscriptions: Vec = self - .cloud_services - .get_user_service()? - .get_workspace_subscriptions() - .await? - .into_iter() - .map(WorkspaceSubscription::from) - .collect(); + if let Ok(subscription) = select_workspace_subscription(db, &workspace_id) { + if is_older_than_n_minutes(subscription.updated_at, 10) { + self + .get_workspace_subscription_info_from_remote(uid, workspace_id) + .await?; + } - for subscription in &subscriptions { - let db = self.authenticate_user.get_sqlite_connection(uid)?; - let record = WorkspaceSubscriptionsTable { - workspace_id: subscription.workspace_id.clone().into(), - subscription_plan: subscription.subscription_plan.clone() as i64, - recurring_interval: subscription.recurring_interval.clone() as i64, - is_active: subscription.canceled_at.is_none(), - has_canceled: subscription.canceled_at.is_some(), - canceled_at: subscription.canceled_at.into(), - updated_at: Utc::now().naive_utc(), - }; - - upsert_workspace_subscription(db, record)?; + return Ok(subscription.into()); } - Ok(subscriptions) + let info = self + .get_workspace_subscription_info_from_remote(uid, workspace_id) + .await?; + + Ok(info) + } + + async fn get_workspace_subscription_info_from_remote( + &self, + uid: i64, + workspace_id: String, + ) -> FlowyResult { + let subscriptions = self + .cloud_services + .get_user_service()? + .get_workspace_subscription_one(workspace_id.clone()) + .await?; + + let info = WorkspaceSubscriptionInfoPB::from(subscriptions); + let record = if info.plan_subscription.workspace_id.is_empty() { + WorkspaceSubscriptionInfoPB::default_from_workspace_id(workspace_id) + } else { + info.clone() + }; + + let db = self.authenticate_user.get_sqlite_connection(uid)?; + upsert_workspace_subscription(db, record)?; + + Ok(info) } #[instrument(level = "info", skip(self), err)] @@ -535,6 +513,16 @@ impl UserManager { Ok(()) } + #[instrument(level = "info", skip(self), err)] + pub async fn invalidate_workspace_subscription_info_cache( + &self, + workspace_id: String, + ) -> FlowyResult<()> { + let uid = self.user_id()?; + let db = self.authenticate_user.get_sqlite_connection(uid)?; + delete_workspace_subscription_from_cache(db, &workspace_id) + } + #[instrument(level = "info", skip(self), err)] pub async fn get_workspace_usage( &self,