diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index e9a5d3f7364f..9a7a4107aeb9 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static void AddBillingOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + // services.AddSingleton(); services.AddLicenseServices(); } } diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index 15a618cca0a1..5dbcd7ddc422 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -8,8 +8,11 @@ public abstract record Plan public ProductTierType ProductTier { get; protected init; } public string Name { get; protected init; } public bool IsAnnual { get; protected init; } + // TODO: Move to the client public string NameLocalizationKey { get; protected init; } + // TODO: Move to the client public string DescriptionLocalizationKey { get; protected init; } + // TODO: Remove public bool CanBeUsedByBusiness { get; protected init; } public int? TrialPeriodDays { get; protected init; } public bool HasSelfHost { get; protected init; } @@ -27,7 +30,9 @@ public abstract record Plan public bool UsersGetPremium { get; protected init; } public bool HasCustomPermissions { get; protected init; } public int UpgradeSortOrder { get; protected init; } + // TODO: Move to the client public int DisplaySortOrder { get; protected init; } + // TODO: Remove public int? LegacyYear { get; protected init; } public bool Disabled { get; protected init; } public PasswordManagerPlanFeatures PasswordManager { get; protected init; } @@ -45,15 +50,19 @@ public record SecretsManagerPlanFeatures public string StripeServiceAccountPlanId { get; init; } public decimal? AdditionalPricePerServiceAccount { get; init; } public short BaseServiceAccount { get; init; } + // TODO: Unused, remove public short? MaxAdditionalServiceAccount { get; init; } public bool HasAdditionalServiceAccountOption { get; init; } // Seats public string StripeSeatPlanId { get; init; } public bool HasAdditionalSeatsOption { get; init; } + // TODO: Remove, SM is never packaged public decimal BasePrice { get; init; } public decimal SeatPrice { get; init; } + // TODO: Remove, SM is never packaged public int BaseSeats { get; init; } public short? MaxSeats { get; init; } + // TODO: Unused, remove public int? MaxAdditionalSeats { get; init; } public bool AllowSeatAutoscale { get; init; } @@ -72,8 +81,10 @@ public record PasswordManagerPlanFeatures public decimal ProviderPortalSeatPrice { get; init; } public bool AllowSeatAutoscale { get; init; } public bool HasAdditionalSeatsOption { get; init; } + // TODO: Remove, never set. public int? MaxAdditionalSeats { get; init; } public int BaseSeats { get; init; } + // TODO: Remove premium access as it's deprecated public bool HasPremiumAccessOption { get; init; } public string StripePremiumAccessPlanId { get; init; } public decimal PremiumAccessOptionPrice { get; init; } @@ -83,6 +94,7 @@ public record PasswordManagerPlanFeatures public bool HasAdditionalStorageOption { get; init; } public decimal AdditionalStoragePricePerGb { get; init; } public string StripeStoragePlanId { get; init; } + // TODO: Remove public short? MaxAdditionalStorage { get; init; } // Feature public short? MaxCollections { get; init; } diff --git a/src/Core/Billing/Pricing/IPricingClient.cs b/src/Core/Billing/Pricing/IPricingClient.cs new file mode 100644 index 000000000000..68577f1db3ed --- /dev/null +++ b/src/Core/Billing/Pricing/IPricingClient.cs @@ -0,0 +1,12 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; + +#nullable enable + +namespace Bit.Core.Billing.Pricing; + +public interface IPricingClient +{ + Task GetPlan(PlanType planType); + Task> ListPlans(); +} diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs new file mode 100644 index 000000000000..b2b24d4cf9f4 --- /dev/null +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -0,0 +1,232 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; +using Proto.Billing.Pricing; + +#nullable enable + +namespace Bit.Core.Billing.Pricing; + +public record PlanAdapter : Plan +{ + public PlanAdapter(PlanResponse planResponse) + { + Type = ToPlanType(planResponse.LookupKey); + ProductTier = ToProductTierType(Type); + Name = planResponse.Name; + IsAnnual = !string.IsNullOrEmpty(planResponse.Cadence) && planResponse.Cadence == "annually"; + NameLocalizationKey = planResponse.AdditionalData?["nameLocalizationKey"]; + DescriptionLocalizationKey = planResponse.AdditionalData?["descriptionLocalizationKey"]; + TrialPeriodDays = planResponse.TrialPeriodDays; + HasSelfHost = HasFeature("selfHost"); + HasPolicies = HasFeature("policies"); + HasGroups = HasFeature("groups"); + HasDirectory = HasFeature("directory"); + HasEvents = HasFeature("events"); + HasTotp = HasFeature("totp"); + Has2fa = HasFeature("2fa"); + HasApi = HasFeature("api"); + HasSso = HasFeature("sso"); + HasKeyConnector = HasFeature("keyConnector"); + HasScim = HasFeature("scim"); + HasResetPassword = HasFeature("resetPassword"); + UsersGetPremium = HasFeature("usersGetPremium"); + UpgradeSortOrder = planResponse.AdditionalData != null + ? int.Parse(planResponse.AdditionalData["upgradeSortOrder"]) + : 0; + DisplaySortOrder = planResponse.AdditionalData != null + ? int.Parse(planResponse.AdditionalData["displaySortOrder"]) + : 0; + HasCustomPermissions = HasFeature("customPermissions"); + Disabled = !planResponse.Available; + PasswordManager = ToPasswordManagerPlanFeatures(planResponse); + SecretsManager = planResponse.SecretsManager != null ? ToSecretsManagerPlanFeatures(planResponse) : null; + + return; + + bool HasFeature(string lookupKey) => planResponse.Features.Any(feature => feature.LookupKey == lookupKey); + } + + #region Mappings + + private static PlanType ToPlanType(string lookupKey) + => lookupKey switch + { + "enterprise-annually" => PlanType.EnterpriseAnnually, + "enterprise-annually-2019" => PlanType.EnterpriseAnnually2019, + "enterprise-annually-2020" => PlanType.EnterpriseAnnually2020, + "enterprise-annually-2023" => PlanType.EnterpriseAnnually2023, + "enterprise-monthly" => PlanType.EnterpriseMonthly, + "enterprise-monthly-2019" => PlanType.EnterpriseMonthly2019, + "enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020, + "enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023, + "families" => PlanType.FamiliesAnnually, + "families-2019" => PlanType.FamiliesAnnually2019, + "free" => PlanType.Free, + "teams-annually" => PlanType.TeamsAnnually, + "teams-annually-2019" => PlanType.TeamsAnnually2019, + "teams-annually-2020" => PlanType.TeamsAnnually2020, + "teams-annually-2023" => PlanType.TeamsAnnually2023, + "teams-monthly" => PlanType.TeamsMonthly, + "teams-monthly-2019" => PlanType.TeamsMonthly2019, + "teams-monthly-2020" => PlanType.TeamsMonthly2020, + "teams-monthly-2023" => PlanType.TeamsMonthly2023, + "teams-starter" => PlanType.TeamsStarter, + "teams-starter-2023" => PlanType.TeamsStarter2023, + _ => throw new BillingException() // TODO: Flesh out + }; + + private static ProductTierType ToProductTierType(PlanType planType) + => planType switch + { + PlanType.Free => ProductTierType.Free, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, + _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, + _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, + _ => throw new BillingException() // TODO: Flesh out + }; + + private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanResponse planResponse) + { + var stripePlanId = GetStripePlanId(planResponse.Seats); + var stripeSeatPlanId = GetStripeSeatPlanId(planResponse.Seats); + var stripeProviderPortalSeatPlanId = planResponse.ManagedSeats?.StripePriceId; + var basePrice = GetBasePrice(planResponse.Seats); + var seatPrice = GetSeatPrice(planResponse.Seats); + var providerPortalSeatPrice = + planResponse.ManagedSeats != null ? decimal.Parse(planResponse.ManagedSeats.Price) : 0; + var scales = planResponse.Seats.KindCase switch + { + PurchasableDTO.KindOneofCase.Scalable => true, + PurchasableDTO.KindOneofCase.Packaged => planResponse.Seats.Packaged.Additional != null, + _ => false + }; + var baseSeats = GetBaseSeats(planResponse.Seats); + var maxSeats = GetMaxSeats(planResponse.Seats); + var baseStorageGb = (short?)planResponse.Storage?.Provided; + var hasAdditionalStorageOption = planResponse.Storage != null; + var stripeStoragePlanId = planResponse.Storage?.StripePriceId; + short? maxCollections = + planResponse.AdditionalData != null && + planResponse.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; + + return new PasswordManagerPlanFeatures + { + StripePlanId = stripePlanId, + StripeSeatPlanId = stripeSeatPlanId, + StripeProviderPortalSeatPlanId = stripeProviderPortalSeatPlanId, + BasePrice = basePrice, + SeatPrice = seatPrice, + ProviderPortalSeatPrice = providerPortalSeatPrice, + AllowSeatAutoscale = scales, + HasAdditionalSeatsOption = scales, + BaseSeats = baseSeats, + MaxSeats = maxSeats, + BaseStorageGb = baseStorageGb, + HasAdditionalStorageOption = hasAdditionalStorageOption, + StripeStoragePlanId = stripeStoragePlanId, + MaxCollections = maxCollections + }; + } + + private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanResponse planResponse) + { + var seats = planResponse.SecretsManager.Seats; + var serviceAccounts = planResponse.SecretsManager.ServiceAccounts; + + var maxServiceAccounts = GetMaxServiceAccounts(serviceAccounts); + var allowServiceAccountsAutoscale = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var stripeServiceAccountPlanId = GetStripeServiceAccountPlanId(serviceAccounts); + var additionalPricePerServiceAccount = GetAdditionalPricePerServiceAccount(serviceAccounts); + var baseServiceAccount = GetBaseServiceAccount(serviceAccounts); + var hasAdditionalServiceAccountOption = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var stripeSeatPlanId = GetStripeSeatPlanId(seats); + var hasAdditionalSeatsOption = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var seatPrice = GetSeatPrice(seats); + var maxSeats = GetMaxSeats(seats); + var allowSeatAutoscale = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var maxProjects = + planResponse.AdditionalData != null && + planResponse.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0; + + return new SecretsManagerPlanFeatures + { + MaxServiceAccounts = maxServiceAccounts, + AllowServiceAccountsAutoscale = allowServiceAccountsAutoscale, + StripeServiceAccountPlanId = stripeServiceAccountPlanId, + AdditionalPricePerServiceAccount = additionalPricePerServiceAccount, + BaseServiceAccount = baseServiceAccount, + HasAdditionalServiceAccountOption = hasAdditionalServiceAccountOption, + StripeSeatPlanId = stripeSeatPlanId, + HasAdditionalSeatsOption = hasAdditionalSeatsOption, + SeatPrice = seatPrice, + MaxSeats = maxSeats, + AllowSeatAutoscale = allowSeatAutoscale, + MaxProjects = maxProjects + }; + } + + private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? null + : decimal.Parse(freeOrScalable.Scalable.Price); + + private static decimal GetBasePrice(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : decimal.Parse(purchasable.Packaged.Price); + + private static int GetBaseSeats(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : purchasable.Packaged.Quantity; + + private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase switch + { + FreeOrScalableDTO.KindOneofCase.Free => (short)freeOrScalable.Free.Quantity, + FreeOrScalableDTO.KindOneofCase.Scalable => (short)freeOrScalable.Scalable.Provided, + _ => 0 + }; + + private static short? GetMaxSeats(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Free ? null : (short)purchasable.Free.Quantity; + + private static short? GetMaxSeats(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity; + + private static short? GetMaxServiceAccounts(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity; + + private static decimal GetSeatPrice(PurchasableDTO purchasable) + => purchasable.KindCase switch + { + PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional != null ? decimal.Parse(purchasable.Packaged.Additional.Price) : 0, + PurchasableDTO.KindOneofCase.Scalable => decimal.Parse(purchasable.Scalable.Price), + _ => 0 + }; + + private static decimal GetSeatPrice(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? 0 + : decimal.Parse(freeOrScalable.Scalable.Price); + + private static string? GetStripePlanId(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? null : purchasable.Packaged.StripePriceId; + + private static string? GetStripeSeatPlanId(PurchasableDTO purchasable) + => purchasable.KindCase switch + { + PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional?.StripePriceId, + PurchasableDTO.KindOneofCase.Scalable => purchasable.Scalable.StripePriceId, + _ => null + }; + + private static string? GetStripeSeatPlanId(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? null + : freeOrScalable.Scalable.StripePriceId; + + private static string? GetStripeServiceAccountPlanId(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? null + : freeOrScalable.Scalable.StripePriceId; + + #endregion +} diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs new file mode 100644 index 000000000000..65fc1761adbd --- /dev/null +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -0,0 +1,92 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Net.Client; +using Proto.Billing.Pricing; + +#nullable enable + +namespace Bit.Core.Billing.Pricing; + +public class PricingClient( + IFeatureService featureService, + GlobalSettings globalSettings) : IPricingClient +{ + public async Task GetPlan(PlanType planType) + { + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); + + if (!usePricingService) + { + return StaticStore.GetPlan(planType); + } + + using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri); + var client = new PasswordManager.PasswordManagerClient(channel); + + var lookupKey = ToLookupKey(planType); + if (string.IsNullOrEmpty(lookupKey)) + { + return null; + } + + try + { + var response = + await client.GetPlanByLookupKeyAsync(new GetPlanByLookupKeyRequest { LookupKey = lookupKey }); + + return new PlanAdapter(response); + } + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.NotFound) + { + return null; + } + } + + public async Task> ListPlans() + { + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); + + if (!usePricingService) + { + return StaticStore.Plans.ToList(); + } + + using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri); + var client = new PasswordManager.PasswordManagerClient(channel); + + var response = await client.ListPlansAsync(new Empty()); + return response.Plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList(); + } + + private static string? ToLookupKey(PlanType planType) + => planType switch + { + PlanType.EnterpriseAnnually => "enterprise-annually", + PlanType.EnterpriseAnnually2019 => "enterprise-annually-2019", + PlanType.EnterpriseAnnually2020 => "enterprise-annually-2020", + PlanType.EnterpriseAnnually2023 => "enterprise-annually-2023", + PlanType.EnterpriseMonthly => "enterprise-monthly", + PlanType.EnterpriseMonthly2019 => "enterprise-monthly-2019", + PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020", + PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023", + PlanType.FamiliesAnnually => "families", + PlanType.FamiliesAnnually2019 => "families-2019", + PlanType.Free => "free", + PlanType.TeamsAnnually => "teams-annually", + PlanType.TeamsAnnually2019 => "teams-annually-2019", + PlanType.TeamsAnnually2020 => "teams-annually-2020", + PlanType.TeamsAnnually2023 => "teams-annually-2023", + PlanType.TeamsMonthly => "teams-monthly", + PlanType.TeamsMonthly2019 => "teams-monthly-2019", + PlanType.TeamsMonthly2020 => "teams-monthly-2020", + PlanType.TeamsMonthly2023 => "teams-monthly-2023", + PlanType.TeamsStarter => "teams-starter", + PlanType.TeamsStarter2023 => "teams-starter-2023", + _ => null + }; +} diff --git a/src/Core/Billing/Pricing/Protos/password-manager.proto b/src/Core/Billing/Pricing/Protos/password-manager.proto new file mode 100644 index 000000000000..69a4c51bd15e --- /dev/null +++ b/src/Core/Billing/Pricing/Protos/password-manager.proto @@ -0,0 +1,92 @@ +syntax = "proto3"; + +option csharp_namespace = "Proto.Billing.Pricing"; + +package plans; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +service PasswordManager { + rpc GetPlanByLookupKey (GetPlanByLookupKeyRequest) returns (PlanResponse); + rpc ListPlans (google.protobuf.Empty) returns (ListPlansResponse); +} + +// Requests +message GetPlanByLookupKeyRequest { + string lookupKey = 1; +} + +// Responses +message PlanResponse { + string name = 1; + string lookupKey = 2; + string tier = 4; + optional string cadence = 6; + optional google.protobuf.Int32Value legacyYear = 8; + bool available = 9; + repeated FeatureDTO features = 10; + PurchasableDTO seats = 11; + optional ScalableDTO managedSeats = 12; + optional ScalableDTO storage = 13; + optional SecretsManagerPurchasablesDTO secretsManager = 14; + optional google.protobuf.Int32Value trialPeriodDays = 15; + repeated string canUpgradeTo = 16; + map additionalData = 17; +} + +message ListPlansResponse { + repeated PlanResponse plans = 1; +} + +// DTOs +message FeatureDTO { + string name = 1; + string lookupKey = 2; +} + +message FreeDTO { + int32 quantity = 2; + string type = 4; +} + +message PackagedDTO { + message AdditionalSeats { + string stripePriceId = 1; + string price = 2; + } + + int32 quantity = 2; + string stripePriceId = 3; + string price = 4; + optional AdditionalSeats additional = 5; + string type = 6; +} + +message ScalableDTO { + int32 provided = 2; + string stripePriceId = 6; + string price = 7; + string type = 9; +} + +message PurchasableDTO { + oneof kind { + FreeDTO free = 1; + PackagedDTO packaged = 2; + ScalableDTO scalable = 3; + } +} + +message FreeOrScalableDTO { + oneof kind { + FreeDTO free = 1; + ScalableDTO scalable = 2; + } +} + +message SecretsManagerPurchasablesDTO { + FreeOrScalableDTO seats = 1; + FreeOrScalableDTO serviceAccounts = 2; +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e0c5564ede8f..0b7435cf8f32 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -164,6 +164,7 @@ public static class FeatureFlagKeys public const string AuthenticatorSyncAndroid = "enable-authenticator-sync-android"; public const string AppReviewPrompt = "app-review-prompt"; public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; + public const string UsePricingService = "use-pricing-service"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index c5cb31d9c53d..83ac3076716f 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -25,6 +25,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -44,6 +50,7 @@ + @@ -62,6 +69,10 @@ + + + + diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index cdbfc7cf3a3c..420151a34f7e 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -81,8 +81,8 @@ public virtual string LicenseDirectory public virtual IDomainVerificationSettings DomainVerification { get; set; } = new DomainVerificationSettings(); public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } - public virtual bool EnableEmailVerification { get; set; } + public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) {