feat: refactor and start integrating AI plans

This commit is contained in:
Mathias Mogensen 2024-07-11 05:42:13 +02:00
parent 4c18b2bc9d
commit 792e6f1370
22 changed files with 620 additions and 334 deletions

View File

@ -95,7 +95,8 @@ class AppFlowyCloudDeepLink {
}
if (_isPaymentSuccessUri(uri)) {
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess();
final plan = uri.queryParameters['plan'];
return getIt<SubscriptionSuccessListenable>().onPaymentSuccess(plan);
}
return _isAuthCallbackDeepLink(uri).fold(

View File

@ -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<FlowyResult<void, FlowyError>> cancelSubscription(String workspaceId);
Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
SubscriptionPlanPB plan,
);
Future<FlowyResult<PaymentLinkPB, FlowyError>> createSubscription(
String workspaceId,
SubscriptionPlanPB plan,
@ -228,9 +232,16 @@ class UserBackendService implements IUserBackendService {
return UserEventLeaveWorkspace(data).send();
}
static Future<FlowyResult<RepeatedWorkspaceSubscriptionPB, FlowyError>>
getWorkspaceSubscriptions() {
return UserEventGetWorkspaceSubscriptions().send();
static Future<FlowyResult<WorkspaceSubscriptionInfoPB, FlowyError>>
getWorkspaceSubscriptionInfo(String workspaceId) {
final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
return UserEventGetWorkspaceSubscriptionInfo(params).send();
}
static Future<FlowyResult<void, FlowyError>>
invalidateWorkspaceSubscriptionCache(String workspaceId) {
final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId;
return UserEventInvalidateWorkspaceSubscriptionInfoCache(params).send();
}
Future<FlowyResult<WorkspaceMemberPB, FlowyError>>
@ -250,15 +261,19 @@ class UserBackendService implements IUserBackendService {
..recurringInterval = RecurringIntervalPB.Year
..workspaceSubscriptionPlan = plan
..successUrl =
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success';
'${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url}/web/payment-success?plan=${plan.toRecognizable()}';
return UserEventSubscribeWorkspace(request).send();
}
@override
Future<FlowyResult<void, FlowyError>> cancelSubscription(
String workspaceId,
SubscriptionPlanPB plan,
) {
final request = UserWorkspaceIdPB()..workspaceId = workspaceId;
final request = CancelWorkspaceSubscriptionPB()
..workspaceId = workspaceId
..plan = plan;
return UserEventCancelWorkspaceSubscription(request).send();
}
}

View File

@ -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<SettingsBillingEvent, SettingsBillingState> {
SettingsBillingBloc({
required this.workspaceId,
required Int64 userId,
}) : super(const _Initial()) {
_userService = UserBackendService(userId: userId);
_service = WorkspaceService(workspaceId: workspaceId);
_successListenable = getIt<SubscriptionSuccessListenable>();
_successListenable.addListener(_onPaymentSuccessful);
on<SettingsBillingEvent>((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(
billingPortalFetched: (billingPortal) async => state.maybeWhen(
orElse: () {},
ready: (subscription, _) => emit(
ready: (subscriptionInfo, _) => emit(
SettingsBillingState.ready(
subscription: subscription,
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<FlowyResult<BillingPortalPB, FlowyError>>();
BillingPortalPB? _billingPortal;
late final SubscriptionSuccessListenable _successListenable;
Future<void> _fetchBillingPortal() async {
final billingPortalResult = await _service.getBillingPortal();
_billingPortalCompleter.complete(billingPortalResult);
}
Future<void> _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;

View File

@ -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<SettingsPlanEvent, SettingsPlanState> {
final snapshots = await Future.wait([
_service.getWorkspaceUsage(),
UserBackendService.getWorkspaceSubscriptions(),
UserBackendService.getWorkspaceSubscriptionInfo(workspaceId),
]);
FlowyError? error;
@ -47,30 +46,24 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
},
);
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<SettingsPlanEvent, SettingsPlanState> {
emit(
SettingsPlanState.ready(
workspaceUsage: usageResult,
subscription: subscription,
subscriptionInfo: subscriptionInfo,
),
);
}
@ -100,7 +93,14 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
.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<SettingsPlanEvent, SettingsPlanState> {
late final IUserBackendService _userService;
late final SubscriptionSuccessListenable _successListenable;
void _onPaymentSuccessful() {
Future<void> _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;

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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<SettingsBillingBloc>(
create: (context) => SettingsBillingBloc(workspaceId: workspaceId)
..add(const SettingsBillingEvent.started()),
create: (_) => SettingsBillingBloc(
workspaceId: workspaceId,
userId: user.id,
)..add(const SettingsBillingEvent.started()),
child: BlocBuilder<SettingsBillingBloc, SettingsBillingState>(
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<bool?>(
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<SettingsBillingBloc>()
.add(SettingsBillingEvent.cancelSubscription(plan));
} else {
// Add/renew the addon
context
.read<SettingsBillingBloc>()
.add(SettingsBillingEvent.addSubscription(plan));
}
},
);
}
}

View File

@ -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<SettingsPlanComparisonDialog> 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<SettingsPlanBloc>()
.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<SettingsPlanBloc>().add(
const SettingsPlanEvent.addSubscription(

View File

@ -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<SettingsPlanBloc>().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<AppearanceSettingsCubit>().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<void> 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(
Expanded(
child: FlowyTextButton(
buttonText,
mainAxisAlignment: MainAxisAlignment.center,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
fillColor: Colors.transparent,
fillColor:
isActive ? const Color(0xFFE8E2EE) : Colors.transparent,
constraints: const BoxConstraints(minWidth: 115),
radius: Corners.s16Border,
hoverColor: const Color(0xFF5C3699),
hoverColor: isActive
? const Color(0xFFE8E2EE)
: const Color(0xFF5C3699),
fontColor: const Color(0xFF5C3699),
fontHoverColor: Colors.white,
borderColor: const Color(0xFF5C3699),
fontHoverColor:
isActive ? const Color(0xFF5C3699) : Colors.white,
borderColor: isActive
? const Color(0xFFE8E2EE)
: const Color(0xFF5C3699),
fontSize: 12,
onPressed: () {},
onPressed: isActive ? null : () {},
),
),
],
),

View File

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

View File

@ -0,0 +1,3 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.31665 11.5668L12.0166 6.86683L11.0833 5.9335L7.31665 9.70016L5.41665 7.80016L4.48331 8.7335L7.31665 11.5668ZM8.24998 15.1668C7.32776 15.1668 6.46109 14.9918 5.64998 14.6418C4.83887 14.2918 4.13331 13.8168 3.53331 13.2168C2.93331 12.6168 2.45831 11.9113 2.10831 11.1002C1.75831 10.2891 1.58331 9.42238 1.58331 8.50016C1.58331 7.57794 1.75831 6.71127 2.10831 5.90016C2.45831 5.08905 2.93331 4.3835 3.53331 3.7835C4.13331 3.1835 4.83887 2.7085 5.64998 2.3585C6.46109 2.0085 7.32776 1.8335 8.24998 1.8335C9.1722 1.8335 10.0389 2.0085 10.85 2.3585C11.6611 2.7085 12.3666 3.1835 12.9666 3.7835C13.5666 4.3835 14.0416 5.08905 14.3916 5.90016C14.7416 6.71127 14.9166 7.57794 14.9166 8.50016C14.9166 9.42238 14.7416 10.2891 14.3916 11.1002C14.0416 11.9113 13.5666 12.6168 12.9666 13.2168C12.3666 13.8168 11.6611 14.2918 10.85 14.6418C10.0389 14.9918 9.1722 15.1668 8.24998 15.1668ZM8.24998 13.8335C9.73887 13.8335 11 13.3168 12.0333 12.2835C13.0666 11.2502 13.5833 9.98905 13.5833 8.50016C13.5833 7.01127 13.0666 5.75016 12.0333 4.71683C11 3.6835 9.73887 3.16683 8.24998 3.16683C6.76109 3.16683 5.49998 3.6835 4.46665 4.71683C3.43331 5.75016 2.91665 7.01127 2.91665 8.50016C2.91665 9.98905 3.43331 11.2502 4.46665 12.2835C5.49998 13.3168 6.76109 13.8335 8.24998 13.8335Z" fill="#653E8C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@ -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::<RepeatedWorkspaceSubscriptionPB>()
}
pub async fn leave_workspace(&self, workspace_id: &str) {
let payload = UserWorkspaceIdPB {
workspace_id: workspace_id.to_string(),

View File

@ -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);
}

View File

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

View File

@ -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<BigInt>,
workspace_status -> BigInt,
end_date -> BigInt,
addons -> Text,
updated_at -> Timestamp,
}
}

View File

@ -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<RecurringInterval> 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<WorkspacePlanPB> for SubscriptionPlanPB {
fn from(value: WorkspacePlanPB) -> Self {
match value {
WorkspacePlanPB::FreePlan => SubscriptionPlanPB::None,
WorkspacePlanPB::ProPlan => SubscriptionPlanPB::Pro,
WorkspacePlanPB::TeamPlan => SubscriptionPlanPB::Team,
}
}
}
impl From<SubscriptionPlanPB> for SubscriptionPlan {
fn from(value: SubscriptionPlanPB) -> Self {
match value {
@ -467,30 +482,55 @@ pub struct WorkspaceSubscriptionInfoPB {
pub add_ons: Vec<WorkspaceAddOnPB>,
}
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<Vec<WorkspaceSubscriptionStatus>> for WorkspaceSubscriptionInfoPB {
fn from(subs: Vec<WorkspaceSubscriptionStatus>) -> 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<Vec<WorkspaceSubscriptionStatus>> for WorkspaceSubscriptionInfoPB {
},
}
}
WorkspaceSubscriptionInfoPB {
plan,
plan_subscription,
@ -506,15 +547,45 @@ impl From<Vec<WorkspaceSubscriptionStatus>> for WorkspaceSubscriptionInfoPB {
}
}
impl From<WorkspaceSubscriptionInfoPB> 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<i64> for WorkspacePlanPB {
fn into(self) -> i64 {
self as i64
}
}
impl From<i64> 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<WorkspaceSubscriptionStatus> for WorkspaceSubscriptionV2PB {
fn from(sub: WorkspaceSubscriptionStatus) -> Self {
Self {
@ -555,13 +637,28 @@ impl From<WorkspaceSubscriptionStatus> 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<i64> for WorkspaceSubscriptionStatusPB {
fn into(self) -> i64 {
self as i64
}
}
impl From<i64> for WorkspaceSubscriptionStatusPB {
fn from(value: i64) -> Self {
match value {
0 => WorkspaceSubscriptionStatusPB::Active,
_ => WorkspaceSubscriptionStatusPB::Canceled,
}
}
}
impl From<SubscriptionStatus> for WorkspaceSubscriptionStatusPB {
fn from(status: SubscriptionStatus) -> Self {
match status {

View File

@ -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<Weak<UserManager>>,
) -> DataResult<RepeatedWorkspaceSubscriptionPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let subs = manager
.get_workspace_subscriptions()
.await?
.into_iter()
.map(WorkspaceSubscriptionPB::from)
.collect::<Vec<_>>();
data_result_ok(RepeatedWorkspaceSubscriptionPB { items: subs })
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_workspace_subscription_info_handler(
params: AFPluginData<UserWorkspaceIdPB>,
@ -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<UserWorkspaceIdPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> 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<WorkspaceMemberIdPB>,

View File

@ -70,15 +70,14 @@ pub fn init(user_manager: Weak<UserManager>) -> 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::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 {

View File

@ -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<i64>,
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<T: Into<WorkspaceSubscriptionsTable>>(
Ok(())
}
impl TryFrom<WorkspaceSubscriptionsTable> for WorkspaceSubscription {
type Error = FlowyError;
fn try_from(value: WorkspaceSubscriptionsTable) -> Result<Self, Self::Error> {
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<WorkspaceSubscriptionInfoPB> 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(),
}
}
}

View File

@ -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<Vec<WorkspaceSubscription>> {
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<Vec<WorkspaceSubscriptionStatus>> {
) -> FlowyResult<WorkspaceSubscriptionInfoPB> {
let session = self.get_session()?;
let uid = session.user_id;
let db = self.authenticate_user.get_sqlite_connection(uid)?;
if let Ok(subscription) = select_workspace_subscription(db, &workspace_id) {
if is_older_than_n_minutes(subscription.updated_at, 10) {
self
.cloud_services
.get_user_service()?
.get_workspace_subscriptions()
.await
.get_workspace_subscription_info_from_remote(uid, workspace_id)
.await?;
}
async fn get_workspace_subscriptions_from_remote(
return Ok(subscription.into());
}
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,
) -> FlowyResult<Vec<WorkspaceSubscription>> {
let subscriptions: Vec<WorkspaceSubscription> = self
workspace_id: String,
) -> FlowyResult<WorkspaceSubscriptionInfoPB> {
let subscriptions = self
.cloud_services
.get_user_service()?
.get_workspace_subscriptions()
.await?
.into_iter()
.map(WorkspaceSubscription::from)
.collect();
.get_workspace_subscription_one(workspace_id.clone())
.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(),
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(subscriptions)
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,