feat: plan+billing (#5518)

* feat: billing client

* feat: subscribe workspace default impl

* feat: added create subscription

* feat: add get workspace subs

* feat: added subscription cancellation

* feat: add workspace limits api

* fix: update client api

* feat: user billing portal

* feat: billing UI (#5455)

* feat: plan ui

* feat: billing ui

* feat: settings plan comparison dialog

* feat: complete plan+billing ui

* feat: backend integration

* chore: cleaning

* chore: fixes after merge

* fix: dependency issue

* feat: added subscription plan cancellation information

* feat: subscription callback + canceled date

* feat: put behind feature flag

* feat: downgrade/upgrade dialogs

* feat: update limit error codes

* fix: billing refresh + downgrade dialog

* fix: some minor improvements to settings

* chore: use patch for client-api in tauri

* fix: add shared-entity to patch

* fix: compile

* ci: try to add back maximize build space step

* test: increase timeout in failing test

---------

Co-authored-by: Zack Fu Zi Xiang <speed2exe@live.com.sg>
This commit is contained in:
Mathias Mogensen
2024-06-12 17:08:55 +02:00
committed by GitHub
parent 3d7a500550
commit 4708c0f779
52 changed files with 2769 additions and 83 deletions

View File

@ -1,7 +1,10 @@
use validator::Validate;
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_user_pub::entities::{Role, WorkspaceInvitation, WorkspaceMember};
use flowy_user_pub::entities::{
RecurringInterval, Role, SubscriptionPlan, WorkspaceInvitation, WorkspaceMember,
WorkspaceSubscription,
};
use lib_infra::validator_fn::required_not_empty_str;
#[derive(ProtoBuf, Default, Clone)]
@ -201,3 +204,136 @@ pub struct ChangeWorkspaceIconPB {
#[pb(index = 2)]
pub new_icon: String,
}
#[derive(ProtoBuf, Default, Clone, Validate, Debug)]
pub struct SubscribeWorkspacePB {
#[pb(index = 1)]
#[validate(custom = "required_not_empty_str")]
pub workspace_id: String,
#[pb(index = 2)]
pub recurring_interval: RecurringIntervalPB,
#[pb(index = 3)]
pub workspace_subscription_plan: SubscriptionPlanPB,
#[pb(index = 4)]
pub success_url: String,
}
#[derive(ProtoBuf_Enum, Clone, Default, Debug)]
pub enum RecurringIntervalPB {
#[default]
Month = 0,
Year = 1,
}
impl From<RecurringIntervalPB> for RecurringInterval {
fn from(r: RecurringIntervalPB) -> Self {
match r {
RecurringIntervalPB::Month => RecurringInterval::Month,
RecurringIntervalPB::Year => RecurringInterval::Year,
}
}
}
impl From<RecurringInterval> for RecurringIntervalPB {
fn from(r: RecurringInterval) -> Self {
match r {
RecurringInterval::Month => RecurringIntervalPB::Month,
RecurringInterval::Year => RecurringIntervalPB::Year,
}
}
}
#[derive(ProtoBuf_Enum, Clone, Default, Debug)]
pub enum SubscriptionPlanPB {
#[default]
None = 0,
Pro = 1,
Team = 2,
}
impl From<SubscriptionPlanPB> for SubscriptionPlan {
fn from(value: SubscriptionPlanPB) -> Self {
match value {
SubscriptionPlanPB::Pro => SubscriptionPlan::Pro,
SubscriptionPlanPB::Team => SubscriptionPlan::Team,
SubscriptionPlanPB::None => SubscriptionPlan::None,
}
}
}
impl From<SubscriptionPlan> for SubscriptionPlanPB {
fn from(value: SubscriptionPlan) -> Self {
match value {
SubscriptionPlan::Pro => SubscriptionPlanPB::Pro,
SubscriptionPlan::Team => SubscriptionPlanPB::Team,
SubscriptionPlan::None => SubscriptionPlanPB::None,
}
}
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct PaymentLinkPB {
#[pb(index = 1)]
pub payment_link: String,
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct RepeatedWorkspaceSubscriptionPB {
#[pb(index = 1)]
pub items: Vec<WorkspaceSubscriptionPB>,
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct WorkspaceSubscriptionPB {
#[pb(index = 1)]
pub workspace_id: String,
#[pb(index = 2)]
pub subscription_plan: SubscriptionPlanPB,
#[pb(index = 3)]
pub recurring_interval: RecurringIntervalPB,
#[pb(index = 4)]
pub is_active: bool,
#[pb(index = 5)]
pub has_canceled: bool,
#[pb(index = 6)]
pub canceled_at: i64, // value is valid only if has_canceled is true
}
impl From<WorkspaceSubscription> for WorkspaceSubscriptionPB {
fn from(s: WorkspaceSubscription) -> Self {
Self {
workspace_id: s.workspace_id,
subscription_plan: s.subscription_plan.into(),
recurring_interval: s.recurring_interval.into(),
is_active: s.is_active,
has_canceled: s.canceled_at.is_some(),
canceled_at: s.canceled_at.unwrap_or_default(),
}
}
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct WorkspaceUsagePB {
#[pb(index = 1)]
pub member_count: u64,
#[pb(index = 2)]
pub member_count_limit: u64,
#[pb(index = 3)]
pub total_blob_bytes: u64,
#[pb(index = 4)]
pub total_blob_bytes_limit: u64,
}
#[derive(Debug, ProtoBuf, Default, Clone)]
pub struct BillingPortalPB {
#[pb(index = 1)]
pub url: String,
}

View File

@ -774,3 +774,64 @@ pub async fn leave_workspace_handler(
manager.leave_workspace(&workspace_id).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn subscribe_workspace_handler(
params: AFPluginData<SubscribeWorkspacePB>,
manager: AFPluginState<Weak<UserManager>>,
) -> DataResult<PaymentLinkPB, FlowyError> {
let params = params.try_into_inner()?;
let manager = upgrade_manager(manager)?;
let payment_link = manager.subscribe_workspace(params).await?;
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 cancel_workspace_subscription_handler(
param: AFPluginData<UserWorkspaceIdPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> Result<(), FlowyError> {
let workspace_id = param.into_inner().workspace_id;
let manager = upgrade_manager(manager)?;
manager.cancel_workspace_subscription(workspace_id).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_workspace_usage_handler(
param: AFPluginData<UserWorkspaceIdPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> DataResult<WorkspaceUsagePB, FlowyError> {
let workspace_id = param.into_inner().workspace_id;
let manager = upgrade_manager(manager)?;
let workspace_usage = manager.get_workspace_usage(workspace_id).await?;
data_result_ok(WorkspaceUsagePB {
member_count: workspace_usage.member_count as u64,
member_count_limit: workspace_usage.member_count_limit as u64,
total_blob_bytes: workspace_usage.total_blob_bytes as u64,
total_blob_bytes_limit: workspace_usage.total_blob_bytes_limit as u64,
})
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_billing_portal_handler(
manager: AFPluginState<Weak<UserManager>>,
) -> DataResult<BillingPortalPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let url = manager.get_billing_portal_url().await?;
data_result_ok(BillingPortalPB { url })
}

View File

@ -71,6 +71,12 @@ pub fn init(user_manager: Weak<UserManager>) -> AFPlugin {
.event(UserEvent::InviteWorkspaceMember, invite_workspace_member_handler)
.event(UserEvent::ListWorkspaceInvitations, list_workspace_invitations_handler)
.event(UserEvent::AcceptWorkspaceInvitation, accept_workspace_invitations_handler)
// Billing
.event(UserEvent::SubscribeWorkspace, subscribe_workspace_handler)
.event(UserEvent::GetWorkspaceSubscriptions, get_workspace_subscriptions_handler)
.event(UserEvent::CancelWorkspaceSubscription, cancel_workspace_subscription_handler)
.event(UserEvent::GetWorkspaceUsage, get_workspace_usage_handler)
.event(UserEvent::GetBillingPortal, get_billing_portal_handler)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@ -230,6 +236,21 @@ pub enum UserEvent {
#[event(input = "MagicLinkSignInPB", output = "UserProfilePB")]
MagicLinkSignIn = 50,
#[event(input = "SubscribeWorkspacePB", output = "PaymentLinkPB")]
SubscribeWorkspace = 51,
#[event(output = "RepeatedWorkspaceSubscriptionPB")]
GetWorkspaceSubscriptions = 52,
#[event(input = "UserWorkspaceIdPB")]
CancelWorkspaceSubscription = 53,
#[event(input = "UserWorkspaceIdPB", output = "WorkspaceUsagePB")]
GetWorkspaceUsage = 54,
#[event(output = "BillingPortalPB")]
GetBillingPortal = 55,
}
pub trait UserStatusCallback: Send + Sync + 'static {

View File

@ -11,10 +11,13 @@ use flowy_sqlite::schema::user_workspace_table;
use flowy_sqlite::{query_dsl::*, DBConnection, ExpressionMethods};
use flowy_user_pub::entities::{
Role, UserWorkspace, WorkspaceInvitation, WorkspaceInvitationStatus, WorkspaceMember,
WorkspaceSubscription, WorkspaceUsage,
};
use lib_dispatch::prelude::af_spawn;
use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB, UserWorkspacePB};
use crate::entities::{
RepeatedUserWorkspacePB, ResetWorkspacePB, SubscribeWorkspacePB, UserWorkspacePB,
};
use crate::migrations::AnonUser;
use crate::notification::{send_notification, UserNotification};
use crate::services::data_import::{
@ -417,6 +420,65 @@ impl UserManager {
.await?;
Ok(())
}
#[instrument(level = "info", skip(self), err)]
pub async fn subscribe_workspace(
&self,
workspace_subscription: SubscribeWorkspacePB,
) -> FlowyResult<String> {
let payment_link = self
.cloud_services
.get_user_service()?
.subscribe_workspace(
workspace_subscription.workspace_id,
workspace_subscription.recurring_interval.into(),
workspace_subscription.workspace_subscription_plan.into(),
workspace_subscription.success_url,
)
.await?;
Ok(payment_link)
}
#[instrument(level = "info", skip(self), err)]
pub async fn get_workspace_subscriptions(&self) -> FlowyResult<Vec<WorkspaceSubscription>> {
let res = self
.cloud_services
.get_user_service()?
.get_workspace_subscriptions()
.await?;
Ok(res)
}
#[instrument(level = "info", skip(self), err)]
pub async fn cancel_workspace_subscription(&self, workspace_id: String) -> FlowyResult<()> {
self
.cloud_services
.get_user_service()?
.cancel_workspace_subscription(workspace_id)
.await?;
Ok(())
}
#[instrument(level = "info", skip(self), err)]
pub async fn get_workspace_usage(&self, workspace_id: String) -> FlowyResult<WorkspaceUsage> {
let workspace_usage = self
.cloud_services
.get_user_service()?
.get_workspace_usage(workspace_id)
.await?;
Ok(workspace_usage)
}
#[instrument(level = "info", skip(self), err)]
pub async fn get_billing_portal_url(&self) -> FlowyResult<String> {
let url = self
.cloud_services
.get_user_service()?
.get_billing_portal_url()
.await?;
Ok(url)
}
}
/// This method is used to save one user workspace to the SQLite database