1
0
mirror of https://github.com/bitwarden/server.git synced 2025-01-07 19:37:51 +01:00

Merge branch 'main' into pm-15807-Move-subscription-to-canceled-7-days-after-unpai'

This commit is contained in:
cyprain-okeke 2025-01-03 16:23:05 +01:00 committed by GitHub
commit 8392ddc4c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 457 additions and 1 deletions

View File

@ -34,6 +34,9 @@ public static class ServiceCollectionExtensions
Url = new Uri("https://github.com/bitwarden/server/blob/master/LICENSE.txt")
}
});
config.CustomSchemaIds(type => type.FullName);
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme

View File

@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
// services.AddSingleton<IPricingClient, PricingClient>();
services.AddLicenseServices();
}
}

View File

@ -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 abstract record Plan
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 abstract record Plan
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 abstract record Plan
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; }

View File

@ -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<Plan?> GetPlan(PlanType planType);
Task<List<Plan>> ListPlans();
}

View File

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

View File

@ -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<Plan?> 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<List<Plan>> 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
};
}

View File

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

View File

@ -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<string> GetAllKeys()
{

View File

@ -25,6 +25,12 @@
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.64" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Google.Protobuf" Version="3.29.2" />
<PackageReference Include="Grpc.Net.Client" Version="2.67.0" />
<PackageReference Include="Grpc.Tools" Version="2.68.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" />
@ -44,6 +50,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.10" />
<PackageReference Include="OneOf" Version="3.0.271" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
@ -62,6 +69,10 @@
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Billing\Pricing\Protos\password-manager.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\" />
<Folder Include="Properties\" />

View File

@ -81,8 +81,8 @@ public class GlobalSettings : IGlobalSettings
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)
{