1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-02 16:42:50 -05:00

[PM-16684] Integrate Pricing Service behind FF (#5276)

* Remove gRPC and convert PricingClient to HttpClient wrapper

* Add PlanType.GetProductTier extension

Many instances of StaticStore use are just to get the ProductTierType of a PlanType, but this can be derived from the PlanType itself without having to fetch the entire plan.

* Remove invocations of the StaticStore in non-Test code

* Deprecate StaticStore entry points

* Run dotnet format

* Matt's feedback

* Run dotnet format

* Rui's feedback

* Run dotnet format

* Replacements since approval

* Run dotnet format
This commit is contained in:
Alex Morask
2025-02-27 07:55:46 -05:00
committed by GitHub
parent bd66f06bd9
commit a2e665cb96
78 changed files with 1178 additions and 712 deletions

View File

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -24,6 +25,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
public UpdateOrganizationUserCommand(
IEventService eventService,
@ -34,7 +36,8 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient)
{
_eventService = eventService;
_organizationService = organizationService;
@ -45,6 +48,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
_collectionRepository = collectionRepository;
_groupRepository = groupRepository;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
}
/// <summary>
@ -128,8 +132,10 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organizationUser.OrganizationId, 1);
if (additionalSmSeatsRequired > 0)
{
var update = new SecretsManagerSubscriptionUpdate(organization, true)
.AdjustSeats(additionalSmSeatsRequired);
// TODO: https://bitwarden.atlassian.net/browse/PM-17012
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate(organization, plan, true)
.AdjustSeats(additionalSmSeatsRequired);
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update);
}
}

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -45,11 +46,12 @@ public class CloudOrganizationSignUpCommand(
IPushRegistrationService pushRegistrationService,
IPushNotificationService pushNotificationService,
ICollectionRepository collectionRepository,
IDeviceRepository deviceRepository) : ICloudOrganizationSignUpCommand
IDeviceRepository deviceRepository,
IPricingClient pricingClient) : ICloudOrganizationSignUpCommand
{
public async Task<SignUpOrganizationResponse> SignUpOrganizationAsync(OrganizationSignup signup)
{
var plan = StaticStore.GetPlan(signup.Plan);
var plan = await pricingClient.GetPlanOrThrow(signup.Plan);
ValidatePasswordManagerPlan(plan, signup);

View File

@ -16,6 +16,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -74,6 +75,7 @@ public class OrganizationService : IOrganizationService
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
public OrganizationService(
IOrganizationRepository organizationRepository,
@ -108,7 +110,8 @@ public class OrganizationService : IOrganizationService
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IOrganizationBillingService organizationBillingService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -143,6 +146,7 @@ public class OrganizationService : IOrganizationService
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_organizationBillingService = organizationBillingService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -210,11 +214,7 @@ public class OrganizationService : IOrganizationService
throw new NotFoundException();
}
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType);
if (plan == null)
{
throw new BadRequestException("Existing plan not found.");
}
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.PasswordManager.HasAdditionalStorageOption)
{
@ -268,7 +268,7 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException($"Cannot set max seat autoscaling below current seat count.");
}
var plan = StaticStore.GetPlan(organization.PlanType);
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
if (plan == null)
{
throw new BadRequestException("Existing plan not found.");
@ -320,11 +320,7 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("No subscription found.");
}
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType);
if (plan == null)
{
throw new BadRequestException("Existing plan not found.");
}
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.PasswordManager.HasAdditionalSeatsOption)
{
@ -442,7 +438,7 @@ public class OrganizationService : IOrganizationService
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
{
var plan = StaticStore.GetPlan(signup.Plan);
var plan = await _pricingClient.GetPlanOrThrow(signup.Plan);
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
@ -530,17 +526,6 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException(exception);
}
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == license.PlanType);
if (plan is null)
{
throw new BadRequestException($"Server must be updated to support {license.Plan}.");
}
if (license.PlanType != PlanType.Custom && plan.Disabled)
{
throw new BadRequestException($"Plan {plan.Name} is disabled.");
}
var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync();
if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey)))
{
@ -882,7 +867,8 @@ public class OrganizationService : IOrganizationService
var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount);
if (additionalSmSeatsRequired > 0)
{
smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, true)
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, plan, true)
.AdjustSeats(additionalSmSeatsRequired);
}
@ -1008,7 +994,8 @@ public class OrganizationService : IOrganizationService
if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue &&
currentOrganization.SmSeats.Value != initialSmSeatCount.Value)
{
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(currentOrganization, false)
var plan = await _pricingClient.GetPlanOrThrow(currentOrganization.PlanType);
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(currentOrganization, plan, false)
{
SmSeats = initialSmSeatCount.Value
};
@ -2237,13 +2224,6 @@ public class OrganizationService : IOrganizationService
public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
{
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType);
if (plan!.Disabled)
{
throw new BadRequestException("Plan not found.");
}
organization.Id = CoreHelpers.GenerateComb();
organization.Enabled = false;
organization.Status = OrganizationStatusType.Pending;

View File

@ -34,6 +34,7 @@ public static class StripeConstants
public static class InvoiceStatus
{
public const string Draft = "draft";
public const string Open = "open";
}
public static class MetadataKeys

View File

@ -10,6 +10,17 @@ namespace Bit.Core.Billing.Extensions;
public static class BillingExtensions
{
public static ProductTierType GetProductTier(this PlanType planType)
=> planType switch
{
PlanType.Custom or 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($"PlanType {planType} could not be matched to a ProductTierType")
};
public static bool IsBillable(this Provider provider) =>
provider is
{

View File

@ -1,6 +1,7 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Caches.Implementations;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
@ -17,7 +18,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
// services.AddSingleton<IPricingClient, PricingClient>();
services.AddLicenseServices();
services.AddPricingClient();
}
}

View File

@ -3,11 +3,11 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using Plan = Bit.Core.Models.StaticStore.Plan;
@ -19,6 +19,7 @@ public class OrganizationMigrator(
ILogger<OrganizationMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : IOrganizationMigrator
{
private const string _cancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
@ -137,7 +138,7 @@ public class OrganizationMigrator(
logger.LogInformation("CB: Bringing organization ({OrganizationID}) under provider management",
organization.Id);
var plan = StaticStore.GetPlan(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly);
var plan = await pricingClient.GetPlanOrThrow(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
@ -206,7 +207,7 @@ public class OrganizationMigrator(
? StripeConstants.CollectionMethod.ChargeAutomatically
: StripeConstants.CollectionMethod.SendInvoice;
var plan = StaticStore.GetPlan(organization.PlanType);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var items = new List<SubscriptionItemOptions>
{
@ -279,7 +280,7 @@ public class OrganizationMigrator(
throw new Exception();
}
var plan = StaticStore.GetPlan(migrationRecord.PlanType);
var plan = await pricingClient.GetPlanOrThrow(migrationRecord.PlanType);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = migrationRecord.MaxStorageGb;

View File

@ -1,24 +1,11 @@
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models;
public record ConfiguredProviderPlan(
Guid Id,
Guid ProviderId,
PlanType PlanType,
Plan Plan,
int SeatMinimum,
int PurchasedSeats,
int AssignedSeats)
{
public static ConfiguredProviderPlan From(ProviderPlan providerPlan) =>
providerPlan.IsConfigured()
? new ConfiguredProviderPlan(
providerPlan.Id,
providerPlan.ProviderId,
providerPlan.PlanType,
providerPlan.SeatMinimum.GetValueOrDefault(0),
providerPlan.PurchasedSeats.GetValueOrDefault(0),
providerPlan.AllocatedSeats.GetValueOrDefault(0))
: null;
}
int AssignedSeats);

View File

@ -10,4 +10,17 @@ public record OrganizationMetadata(
bool IsSubscriptionCanceled,
DateTime? InvoiceDueDate,
DateTime? InvoiceCreatedDate,
DateTime? SubPeriodEndDate);
DateTime? SubPeriodEndDate)
{
public static OrganizationMetadata Default => new OrganizationMetadata(
false,
false,
false,
false,
false,
false,
false,
null,
null,
null);
}

View File

@ -76,8 +76,6 @@ public class OrganizationSale
private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade)
{
var plan = Core.Utilities.StaticStore.GetPlan(upgrade.Plan);
var passwordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = upgrade.AdditionalSeats,
@ -95,7 +93,7 @@ public class OrganizationSale
return new SubscriptionSetup
{
Plan = plan,
PlanType = upgrade.Plan,
PasswordManagerOptions = passwordManagerOptions,
SecretsManagerOptions = secretsManagerOptions
};

View File

@ -1,4 +1,4 @@
using Bit.Core.Models.StaticStore;
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Models.Sales;
@ -6,7 +6,7 @@ namespace Bit.Core.Billing.Models.Sales;
public class SubscriptionSetup
{
public required Plan Plan { get; set; }
public required PlanType PlanType { get; set; }
public required PasswordManager PasswordManagerOptions { get; set; }
public SecretsManager? SecretsManagerOptions { get; set; }
public bool SkipTrial = false;

View File

@ -1,5 +1,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.StaticStore;
using Bit.Core.Utilities;
#nullable enable
@ -7,6 +9,30 @@ namespace Bit.Core.Billing.Pricing;
public interface IPricingClient
{
/// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
/// </summary>
/// <param name="planType">The type of plan to retrieve.</param>
/// <returns>A Bitwarden <see cref="Plan"/> record or null in the case the plan could not be found or the method was executed from a self-hosted instance.</returns>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan?> GetPlan(PlanType planType);
/// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
/// </summary>
/// <param name="planType">The type of plan to retrieve.</param>
/// <returns>A Bitwarden <see cref="Plan"/> record.</returns>
/// <exception cref="NotFoundException">Thrown when the <see cref="Plan"/> for the provided <paramref name="planType"/> could not be found or the method was executed from a self-hosted instance.</exception>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan> GetPlanOrThrow(PlanType planType);
/// <summary>
/// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
/// </summary>
/// <returns>A list of Bitwarden <see cref="Plan"/> records or an empty list in the case the method is executed from a self-hosted instance.</returns>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<List<Plan>> ListPlans();
}

View File

@ -0,0 +1,35 @@
using System.Text.Json;
using Bit.Core.Billing.Pricing.Models;
namespace Bit.Core.Billing.Pricing.JSON;
#nullable enable
public class FreeOrScalableDTOJsonConverter : TypeReadingJsonConverter<FreeOrScalableDTO>
{
public override FreeOrScalableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var type = ReadType(reader);
return type switch
{
"free" => JsonSerializer.Deserialize<FreeDTO>(ref reader, options) switch
{
null => null,
var free => new FreeOrScalableDTO(free)
},
"scalable" => JsonSerializer.Deserialize<ScalableDTO>(ref reader, options) switch
{
null => null,
var scalable => new FreeOrScalableDTO(scalable)
},
_ => null
};
}
public override void Write(Utf8JsonWriter writer, FreeOrScalableDTO value, JsonSerializerOptions options)
=> value.Switch(
free => JsonSerializer.Serialize(writer, free, options),
scalable => JsonSerializer.Serialize(writer, scalable, options)
);
}

View File

@ -0,0 +1,40 @@
using System.Text.Json;
using Bit.Core.Billing.Pricing.Models;
namespace Bit.Core.Billing.Pricing.JSON;
#nullable enable
internal class PurchasableDTOJsonConverter : TypeReadingJsonConverter<PurchasableDTO>
{
public override PurchasableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var type = ReadType(reader);
return type switch
{
"free" => JsonSerializer.Deserialize<FreeDTO>(ref reader, options) switch
{
null => null,
var free => new PurchasableDTO(free)
},
"packaged" => JsonSerializer.Deserialize<PackagedDTO>(ref reader, options) switch
{
null => null,
var packaged => new PurchasableDTO(packaged)
},
"scalable" => JsonSerializer.Deserialize<ScalableDTO>(ref reader, options) switch
{
null => null,
var scalable => new PurchasableDTO(scalable)
},
_ => null
};
}
public override void Write(Utf8JsonWriter writer, PurchasableDTO value, JsonSerializerOptions options)
=> value.Switch(
free => JsonSerializer.Serialize(writer, free, options),
packaged => JsonSerializer.Serialize(writer, packaged, options),
scalable => JsonSerializer.Serialize(writer, scalable, options)
);
}

View File

@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Pricing.Models;
namespace Bit.Core.Billing.Pricing.JSON;
#nullable enable
public abstract class TypeReadingJsonConverter<T> : JsonConverter<T>
{
protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower();
protected string? ReadType(Utf8JsonReader reader)
{
while (reader.Read())
{
if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString()?.ToLower() != TypePropertyName)
{
continue;
}
reader.Read();
return reader.GetString();
}
return null;
}
}

View File

@ -0,0 +1,9 @@
namespace Bit.Core.Billing.Pricing.Models;
#nullable enable
public class FeatureDTO
{
public string Name { get; set; } = null!;
public string LookupKey { get; set; } = null!;
}

View File

@ -0,0 +1,27 @@
namespace Bit.Core.Billing.Pricing.Models;
#nullable enable
public class PlanDTO
{
public string LookupKey { get; set; } = null!;
public string Name { get; set; } = null!;
public string Tier { get; set; } = null!;
public string? Cadence { get; set; }
public int? LegacyYear { get; set; }
public bool Available { get; set; }
public FeatureDTO[] Features { get; set; } = null!;
public PurchasableDTO Seats { get; set; } = null!;
public ScalableDTO? ManagedSeats { get; set; }
public ScalableDTO? Storage { get; set; }
public SecretsManagerPurchasablesDTO? SecretsManager { get; set; }
public int? TrialPeriodDays { get; set; }
public string[] CanUpgradeTo { get; set; } = null!;
public Dictionary<string, string> AdditionalData { get; set; } = null!;
}
public class SecretsManagerPurchasablesDTO
{
public FreeOrScalableDTO Seats { get; set; } = null!;
public FreeOrScalableDTO ServiceAccounts { get; set; } = null!;
}

View File

@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
using Bit.Core.Billing.Pricing.JSON;
using OneOf;
namespace Bit.Core.Billing.Pricing.Models;
#nullable enable
[JsonConverter(typeof(PurchasableDTOJsonConverter))]
public class PurchasableDTO(OneOf<FreeDTO, PackagedDTO, ScalableDTO> input) : OneOfBase<FreeDTO, PackagedDTO, ScalableDTO>(input)
{
public static implicit operator PurchasableDTO(FreeDTO free) => new(free);
public static implicit operator PurchasableDTO(PackagedDTO packaged) => new(packaged);
public static implicit operator PurchasableDTO(ScalableDTO scalable) => new(scalable);
public T? FromFree<T>(Func<FreeDTO, T> select, Func<PurchasableDTO, T>? fallback = null) =>
IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default;
public T? FromPackaged<T>(Func<PackagedDTO, T> select, Func<PurchasableDTO, T>? fallback = null) =>
IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default;
public T? FromScalable<T>(Func<ScalableDTO, T> select, Func<PurchasableDTO, T>? fallback = null) =>
IsT2 ? select(AsT2) : fallback != null ? fallback(this) : default;
public bool IsFree => IsT0;
public bool IsPackaged => IsT1;
public bool IsScalable => IsT2;
}
[JsonConverter(typeof(FreeOrScalableDTOJsonConverter))]
public class FreeOrScalableDTO(OneOf<FreeDTO, ScalableDTO> input) : OneOfBase<FreeDTO, ScalableDTO>(input)
{
public static implicit operator FreeOrScalableDTO(FreeDTO freeDTO) => new(freeDTO);
public static implicit operator FreeOrScalableDTO(ScalableDTO scalableDTO) => new(scalableDTO);
public T? FromFree<T>(Func<FreeDTO, T> select, Func<FreeOrScalableDTO, T>? fallback = null) =>
IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default;
public T? FromScalable<T>(Func<ScalableDTO, T> select, Func<FreeOrScalableDTO, T>? fallback = null) =>
IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default;
public bool IsFree => IsT0;
public bool IsScalable => IsT1;
}
public class FreeDTO
{
public int Quantity { get; set; }
public string Type => "free";
}
public class PackagedDTO
{
public int Quantity { get; set; }
public string StripePriceId { get; set; } = null!;
public decimal Price { get; set; }
public AdditionalSeats? Additional { get; set; }
public string Type => "packaged";
public class AdditionalSeats
{
public string StripePriceId { get; set; } = null!;
public decimal Price { get; set; }
}
}
public class ScalableDTO
{
public int Provided { get; set; }
public string StripePriceId { get; set; } = null!;
public decimal Price { get; set; }
public string Type => "scalable";
}

View File

@ -1,6 +1,6 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing.Models;
using Bit.Core.Models.StaticStore;
using Proto.Billing.Pricing;
#nullable enable
@ -8,15 +8,15 @@ namespace Bit.Core.Billing.Pricing;
public record PlanAdapter : Plan
{
public PlanAdapter(PlanResponse planResponse)
public PlanAdapter(PlanDTO plan)
{
Type = ToPlanType(planResponse.LookupKey);
Type = ToPlanType(plan.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;
Name = plan.Name;
IsAnnual = plan.Cadence is "annually";
NameLocalizationKey = plan.AdditionalData["nameLocalizationKey"];
DescriptionLocalizationKey = plan.AdditionalData["descriptionLocalizationKey"];
TrialPeriodDays = plan.TrialPeriodDays;
HasSelfHost = HasFeature("selfHost");
HasPolicies = HasFeature("policies");
HasGroups = HasFeature("groups");
@ -30,20 +30,20 @@ public record PlanAdapter : Plan
HasScim = HasFeature("scim");
HasResetPassword = HasFeature("resetPassword");
UsersGetPremium = HasFeature("usersGetPremium");
UpgradeSortOrder = planResponse.AdditionalData != null
? int.Parse(planResponse.AdditionalData["upgradeSortOrder"])
UpgradeSortOrder = plan.AdditionalData.TryGetValue("upgradeSortOrder", out var upgradeSortOrder)
? int.Parse(upgradeSortOrder)
: 0;
DisplaySortOrder = planResponse.AdditionalData != null
? int.Parse(planResponse.AdditionalData["displaySortOrder"])
DisplaySortOrder = plan.AdditionalData.TryGetValue("displaySortOrder", out var displaySortOrder)
? int.Parse(displaySortOrder)
: 0;
HasCustomPermissions = HasFeature("customPermissions");
Disabled = !planResponse.Available;
PasswordManager = ToPasswordManagerPlanFeatures(planResponse);
SecretsManager = planResponse.SecretsManager != null ? ToSecretsManagerPlanFeatures(planResponse) : null;
Disabled = !plan.Available;
LegacyYear = plan.LegacyYear;
PasswordManager = ToPasswordManagerPlanFeatures(plan);
SecretsManager = plan.SecretsManager != null ? ToSecretsManagerPlanFeatures(plan) : null;
return;
bool HasFeature(string lookupKey) => planResponse.Features.Any(feature => feature.LookupKey == lookupKey);
bool HasFeature(string lookupKey) => plan.Features.Any(feature => feature.LookupKey == lookupKey);
}
#region Mappings
@ -86,29 +86,25 @@ public record PlanAdapter : Plan
_ => throw new BillingException() // TODO: Flesh out
};
private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanResponse planResponse)
private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanDTO plan)
{
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;
var stripePlanId = GetStripePlanId(plan.Seats);
var stripeSeatPlanId = GetStripeSeatPlanId(plan.Seats);
var stripeProviderPortalSeatPlanId = plan.ManagedSeats?.StripePriceId;
var basePrice = GetBasePrice(plan.Seats);
var seatPrice = GetSeatPrice(plan.Seats);
var providerPortalSeatPrice = plan.ManagedSeats?.Price ?? 0;
var scales = plan.Seats.Match(
_ => false,
packaged => packaged.Additional != null,
_ => true);
var baseSeats = GetBaseSeats(plan.Seats);
var maxSeats = GetMaxSeats(plan.Seats);
var baseStorageGb = (short?)plan.Storage?.Provided;
var hasAdditionalStorageOption = plan.Storage != null;
var additionalStoragePricePerGb = plan.Storage?.Price ?? 0;
var stripeStoragePlanId = plan.Storage?.StripePriceId;
short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null;
return new PasswordManagerPlanFeatures
{
@ -124,30 +120,29 @@ public record PlanAdapter : Plan
MaxSeats = maxSeats,
BaseStorageGb = baseStorageGb,
HasAdditionalStorageOption = hasAdditionalStorageOption,
AdditionalStoragePricePerGb = additionalStoragePricePerGb,
StripeStoragePlanId = stripeStoragePlanId,
MaxCollections = maxCollections
};
}
private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanResponse planResponse)
private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanDTO plan)
{
var seats = planResponse.SecretsManager.Seats;
var serviceAccounts = planResponse.SecretsManager.ServiceAccounts;
var seats = plan.SecretsManager!.Seats;
var serviceAccounts = plan.SecretsManager.ServiceAccounts;
var maxServiceAccounts = GetMaxServiceAccounts(serviceAccounts);
var allowServiceAccountsAutoscale = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable;
var allowServiceAccountsAutoscale = serviceAccounts.IsScalable;
var stripeServiceAccountPlanId = GetStripeServiceAccountPlanId(serviceAccounts);
var additionalPricePerServiceAccount = GetAdditionalPricePerServiceAccount(serviceAccounts);
var baseServiceAccount = GetBaseServiceAccount(serviceAccounts);
var hasAdditionalServiceAccountOption = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable;
var hasAdditionalServiceAccountOption = serviceAccounts.IsScalable;
var stripeSeatPlanId = GetStripeSeatPlanId(seats);
var hasAdditionalSeatsOption = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable;
var hasAdditionalSeatsOption = seats.IsScalable;
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;
var allowSeatAutoscale = seats.IsScalable;
var maxProjects = plan.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0;
return new SecretsManagerPlanFeatures
{
@ -167,66 +162,54 @@ public record PlanAdapter : Plan
}
private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalableDTO freeOrScalable)
=> freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable
? null
: decimal.Parse(freeOrScalable.Scalable.Price);
=> freeOrScalable.FromScalable(x => x.Price);
private static decimal GetBasePrice(PurchasableDTO purchasable)
=> purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : decimal.Parse(purchasable.Packaged.Price);
=> purchasable.FromPackaged(x => x.Price);
private static int GetBaseSeats(PurchasableDTO purchasable)
=> purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : purchasable.Packaged.Quantity;
=> purchasable.FromPackaged(x => x.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
};
=> freeOrScalable.Match(
free => (short)free.Quantity,
scalable => (short)scalable.Provided);
private static short? GetMaxSeats(PurchasableDTO purchasable)
=> purchasable.KindCase != PurchasableDTO.KindOneofCase.Free ? null : (short)purchasable.Free.Quantity;
=> purchasable.Match<short?>(
free => (short)free.Quantity,
packaged => (short)packaged.Quantity,
_ => null);
private static short? GetMaxSeats(FreeOrScalableDTO freeOrScalable)
=> freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity;
=> freeOrScalable.FromFree(x => (short)x.Quantity);
private static short? GetMaxServiceAccounts(FreeOrScalableDTO freeOrScalable)
=> freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity;
=> freeOrScalable.FromFree(x => (short)x.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
};
=> purchasable.Match(
_ => 0,
packaged => packaged.Additional?.Price ?? 0,
scalable => scalable.Price);
private static decimal GetSeatPrice(FreeOrScalableDTO freeOrScalable)
=> freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable
? 0
: decimal.Parse(freeOrScalable.Scalable.Price);
=> freeOrScalable.FromScalable(x => x.Price);
private static string? GetStripePlanId(PurchasableDTO purchasable)
=> purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? null : purchasable.Packaged.StripePriceId;
=> purchasable.FromPackaged(x => x.StripePriceId);
private static string? GetStripeSeatPlanId(PurchasableDTO purchasable)
=> purchasable.KindCase switch
{
PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional?.StripePriceId,
PurchasableDTO.KindOneofCase.Scalable => purchasable.Scalable.StripePriceId,
_ => null
};
=> purchasable.Match(
_ => null,
packaged => packaged.Additional?.StripePriceId,
scalable => scalable.StripePriceId);
private static string? GetStripeSeatPlanId(FreeOrScalableDTO freeOrScalable)
=> freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable
? null
: freeOrScalable.Scalable.StripePriceId;
=> freeOrScalable.FromScalable(x => x.StripePriceId);
private static string? GetStripeServiceAccountPlanId(FreeOrScalableDTO freeOrScalable)
=> freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable
? null
: freeOrScalable.Scalable.StripePriceId;
=> freeOrScalable.FromScalable(x => x.StripePriceId);
#endregion
}

View File

@ -1,12 +1,13 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
using System.Net;
using System.Net.Http.Json;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing.Models;
using Bit.Core.Exceptions;
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;
using Microsoft.Extensions.Logging;
using Plan = Bit.Core.Models.StaticStore.Plan;
#nullable enable
@ -14,10 +15,17 @@ namespace Bit.Core.Billing.Pricing;
public class PricingClient(
IFeatureService featureService,
GlobalSettings globalSettings) : IPricingClient
GlobalSettings globalSettings,
HttpClient httpClient,
ILogger<PricingClient> logger) : IPricingClient
{
public async Task<Plan?> GetPlan(PlanType planType)
{
if (globalSettings.SelfHosted)
{
return null;
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
if (!usePricingService)
@ -25,30 +33,55 @@ public class PricingClient(
return StaticStore.GetPlan(planType);
}
using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri);
var client = new PasswordManager.PasswordManagerClient(channel);
var lookupKey = GetLookupKey(planType);
var lookupKey = ToLookupKey(planType);
if (string.IsNullOrEmpty(lookupKey))
if (lookupKey == null)
{
logger.LogError("Could not find Pricing Service lookup key for PlanType {PlanType}", planType);
return null;
}
try
{
var response =
await client.GetPlanByLookupKeyAsync(new GetPlanByLookupKeyRequest { LookupKey = lookupKey });
var response = await httpClient.GetAsync($"plans/lookup/{lookupKey}");
return new PlanAdapter(response);
}
catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.NotFound)
if (response.IsSuccessStatusCode)
{
var plan = await response.Content.ReadFromJsonAsync<PlanDTO>();
if (plan == null)
{
throw new BillingException(message: "Deserialization of Pricing Service response resulted in null");
}
return new PlanAdapter(plan);
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
logger.LogError("Pricing Service plan for PlanType {PlanType} was not found", planType);
return null;
}
throw new BillingException(
message: $"Request to the Pricing Service failed with status code {response.StatusCode}");
}
public async Task<Plan> GetPlanOrThrow(PlanType planType)
{
var plan = await GetPlan(planType);
if (plan == null)
{
throw new NotFoundException();
}
return plan;
}
public async Task<List<Plan>> ListPlans()
{
if (globalSettings.SelfHosted)
{
return [];
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
if (!usePricingService)
@ -56,14 +89,23 @@ public class PricingClient(
return StaticStore.Plans.ToList();
}
using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri);
var client = new PasswordManager.PasswordManagerClient(channel);
var response = await httpClient.GetAsync("plans");
var response = await client.ListPlansAsync(new Empty());
return response.Plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList();
if (response.IsSuccessStatusCode)
{
var plans = await response.Content.ReadFromJsonAsync<List<PlanDTO>>();
if (plans == null)
{
throw new BillingException(message: "Deserialization of Pricing Service response resulted in null");
}
return plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList();
}
throw new BillingException(
message: $"Request to the Pricing Service failed with status {response.StatusCode}");
}
private static string? ToLookupKey(PlanType planType)
private static string? GetLookupKey(PlanType planType)
=> planType switch
{
PlanType.EnterpriseAnnually => "enterprise-annually",

View File

@ -1,92 +0,0 @@
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

@ -0,0 +1,21 @@
using Bit.Core.Settings;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Pricing;
public static class ServiceCollectionExtensions
{
public static void AddPricingClient(this IServiceCollection services)
{
services.AddHttpClient<IPricingClient, PricingClient>((serviceProvider, httpClient) =>
{
var globalSettings = serviceProvider.GetRequiredService<GlobalSettings>();
if (string.IsNullOrEmpty(globalSettings.PricingUri))
{
return;
}
httpClient.BaseAddress = new Uri(globalSettings.PricingUri);
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
});
}
}

View File

@ -3,12 +3,12 @@ using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
@ -26,6 +26,7 @@ public class OrganizationBillingService(
IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
@ -63,13 +64,22 @@ public class OrganizationBillingService(
return null;
}
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
if (globalSettings.SelfHosted)
{
return OrganizationMetadata.Default;
}
var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false,
false, false, false, false, null, null, null);
return OrganizationMetadata.Default with
{
IsEligibleForSelfHost = isEligibleForSelfHost,
IsManaged = isManaged
};
}
var customer = await subscriberService.GetCustomer(organization,
@ -77,18 +87,21 @@ public class OrganizationBillingService(
var subscription = await subscriberService.GetSubscription(organization);
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
var isSubscriptionCanceled = IsSubscriptionCanceled(subscription);
var hasSubscription = true;
var openInvoice = await HasOpenInvoiceAsync(subscription);
var hasOpenInvoice = openInvoice.HasOpenInvoice;
var invoiceDueDate = openInvoice.DueDate;
var invoiceCreatedDate = openInvoice.CreatedDate;
var subPeriodEndDate = subscription?.CurrentPeriodEnd;
var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription);
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
isSubscriptionUnpaid, hasSubscription, hasOpenInvoice, isSubscriptionCanceled, invoiceDueDate, invoiceCreatedDate, subPeriodEndDate);
var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions());
return new OrganizationMetadata(
isEligibleForSelfHost,
isManaged,
isOnSecretsManagerStandalone,
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid,
true,
invoice?.Status == StripeConstants.InvoiceStatus.Open,
subscription.Status == StripeConstants.SubscriptionStatus.Canceled,
invoice?.DueDate,
invoice?.Created,
subscription.CurrentPeriodEnd);
}
public async Task UpdatePaymentMethod(
@ -299,7 +312,7 @@ public class OrganizationBillingService(
Customer customer,
SubscriptionSetup subscriptionSetup)
{
var plan = subscriptionSetup.Plan;
var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType);
var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions;
@ -385,15 +398,17 @@ public class OrganizationBillingService(
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
}
private static bool IsEligibleForSelfHost(
private async Task<bool> IsEligibleForSelfHostAsync(
Organization organization)
{
var eligibleSelfHostPlans = StaticStore.Plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type);
var plans = await pricingClient.ListPlans();
var eligibleSelfHostPlans = plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type);
return eligibleSelfHostPlans.Contains(organization.PlanType);
}
private static bool IsOnSecretsManagerStandalone(
private async Task<bool> IsOnSecretsManagerStandalone(
Organization organization,
Customer? customer,
Subscription? subscription)
@ -403,7 +418,7 @@ public class OrganizationBillingService(
return false;
}
var plan = StaticStore.GetPlan(organization.PlanType);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.SupportsSecretsManager)
{
@ -424,38 +439,5 @@ public class OrganizationBillingService(
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
}
private static bool IsSubscriptionUnpaid(Subscription subscription)
{
if (subscription == null)
{
return false;
}
return subscription.Status == "unpaid";
}
private async Task<(bool HasOpenInvoice, DateTime? CreatedDate, DateTime? DueDate)> HasOpenInvoiceAsync(Subscription subscription)
{
if (subscription?.LatestInvoiceId == null)
{
return (false, null, null);
}
var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions());
return invoice?.Status == "open"
? (true, invoice.Created, invoice.DueDate)
: (false, null, null);
}
private static bool IsSubscriptionCanceled(Subscription subscription)
{
if (subscription == null)
{
return false;
}
return subscription.Status == "canceled";
}
#endregion
}

View File

@ -27,12 +27,6 @@
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.85" />
<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" />
@ -78,11 +72,7 @@
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Billing\Pricing\Protos\password-manager.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\" />
<Folder Include="Properties\" />

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Exceptions;
using Stripe;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Core.Models.Business;
@ -9,7 +10,7 @@ namespace Bit.Core.Models.Business;
/// </summary>
public class SubscriptionData
{
public StaticStore.Plan Plan { get; init; }
public Plan Plan { get; init; }
public int PurchasedPasswordManagerSeats { get; init; }
public bool SubscribedToSecretsManager { get; set; }
public int? PurchasedSecretsManagerSeats { get; init; }
@ -38,22 +39,24 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
/// in the case of an error.
/// </summary>
/// <param name="organization">The <see cref="Organization"/> to upgrade.</param>
/// <param name="plan">The organization's plan.</param>
/// <param name="updatedSubscription">The updates you want to apply to the organization's subscription.</param>
public CompleteSubscriptionUpdate(
Organization organization,
Plan plan,
SubscriptionData updatedSubscription)
{
_currentSubscription = GetSubscriptionDataFor(organization);
_currentSubscription = GetSubscriptionDataFor(organization, plan);
_updatedSubscription = updatedSubscription;
}
protected override List<string> PlanIds => new()
{
protected override List<string> PlanIds =>
[
GetPasswordManagerPlanId(_updatedSubscription.Plan),
_updatedSubscription.Plan.SecretsManager.StripeSeatPlanId,
_updatedSubscription.Plan.SecretsManager.StripeServiceAccountPlanId,
_updatedSubscription.Plan.PasswordManager.StripeStoragePlanId
};
];
/// <summary>
/// Generates the <see cref="SubscriptionItemOptions"/> necessary to revert an <see cref="Organization"/>'s
@ -94,7 +97,7 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
*/
/// <summary>
/// Checks whether the updates provided in the <see cref="CompleteSubscriptionUpdate"/>'s constructor
/// are actually different than the organization's current <see cref="Subscription"/>.
/// are actually different from the organization's current <see cref="Subscription"/>.
/// </summary>
/// <param name="subscription">The organization's <see cref="Subscription"/>.</param>
public override bool UpdateNeeded(Subscription subscription)
@ -278,11 +281,8 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
};
}
private static SubscriptionData GetSubscriptionDataFor(Organization organization)
{
var plan = Utilities.StaticStore.GetPlan(organization.PlanType);
return new SubscriptionData
private static SubscriptionData GetSubscriptionDataFor(Organization organization, Plan plan)
=> new()
{
Plan = plan,
PurchasedPasswordManagerSeats = organization.Seats.HasValue
@ -299,5 +299,4 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
? organization.MaxStorageGb.Value - (plan.PasswordManager.BaseStorageGb ?? 0) :
0
};
}
}

View File

@ -2,6 +2,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Stripe;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Core.Models.Business;
@ -14,18 +15,16 @@ public class ProviderSubscriptionUpdate : SubscriptionUpdate
protected override List<string> PlanIds => [_planId];
public ProviderSubscriptionUpdate(
PlanType planType,
Plan plan,
int previouslyPurchasedSeats,
int newlyPurchasedSeats)
{
if (!planType.SupportsConsolidatedBilling())
if (!plan.Type.SupportsConsolidatedBilling())
{
throw new BillingException(
message: $"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
}
var plan = Utilities.StaticStore.GetPlan(planType);
_planId = plan.PasswordManager.StripeProviderPortalSeatPlanId;
_previouslyPurchasedSeats = previouslyPurchasedSeats;
_newlyPurchasedSeats = newlyPurchasedSeats;

View File

@ -7,6 +7,7 @@ namespace Bit.Core.Models.Business;
public class SecretsManagerSubscriptionUpdate
{
public Organization Organization { get; }
public Plan Plan { get; }
/// <summary>
/// The total seats the organization will have after the update, including any base seats included in the plan
@ -49,21 +50,16 @@ public class SecretsManagerSubscriptionUpdate
public bool MaxAutoscaleSmSeatsChanged => MaxAutoscaleSmSeats != Organization.MaxAutoscaleSmSeats;
public bool MaxAutoscaleSmServiceAccountsChanged =>
MaxAutoscaleSmServiceAccounts != Organization.MaxAutoscaleSmServiceAccounts;
public Plan Plan => Utilities.StaticStore.GetPlan(Organization.PlanType);
public bool SmSeatAutoscaleLimitReached => SmSeats.HasValue && MaxAutoscaleSmSeats.HasValue && SmSeats == MaxAutoscaleSmSeats;
public bool SmServiceAccountAutoscaleLimitReached => SmServiceAccounts.HasValue &&
MaxAutoscaleSmServiceAccounts.HasValue &&
SmServiceAccounts == MaxAutoscaleSmServiceAccounts;
public SecretsManagerSubscriptionUpdate(Organization organization, bool autoscaling)
public SecretsManagerSubscriptionUpdate(Organization organization, Plan plan, bool autoscaling)
{
if (organization == null)
{
throw new NotFoundException("Organization is not found.");
}
Organization = organization;
Organization = organization ?? throw new NotFoundException("Organization is not found.");
Plan = plan;
if (!Plan.SupportsSecretsManager)
{

View File

@ -82,7 +82,6 @@ public class SubscriptionInfo
}
public bool AddonSubscriptionItem { get; set; }
public string ProductId { get; set; }
public string Name { get; set; }
public decimal Amount { get; set; }

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -54,8 +55,9 @@ public class CloudSyncSponsorshipsCommand : ICloudSyncSponsorshipsCommand
foreach (var selfHostedSponsorship in sponsorshipsData)
{
var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(selfHostedSponsorship.PlanSponsorshipType)?.SponsoringProductTierType;
var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier();
if (requiredSponsoringProductType == null
|| StaticStore.GetPlan(sponsoringOrg.PlanType).ProductTier != requiredSponsoringProductType.Value)
|| sponsoringOrgProductTier != requiredSponsoringProductType.Value)
{
continue; // prevent unsupported sponsorships
}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@ -50,9 +51,10 @@ public class SetUpSponsorshipCommand : ISetUpSponsorshipCommand
// Check org to sponsor's product type
var requiredSponsoredProductType = StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value)?.SponsoredProductTierType;
var sponsoredOrganizationProductTier = sponsoredOrganization.PlanType.GetProductTier();
if (requiredSponsoredProductType == null ||
sponsoredOrganization == null ||
StaticStore.GetPlan(sponsoredOrganization.PlanType).ProductTier != requiredSponsoredProductType.Value)
sponsoredOrganizationProductTier != requiredSponsoredProductType.Value)
{
throw new BadRequestException("Can only redeem sponsorship offer on families organizations.");
}

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Entities;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
@ -103,8 +104,6 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo
return false;
}
var sponsoringOrgPlan = Utilities.StaticStore.GetPlan(sponsoringOrganization.PlanType);
if (OrgDisabledForMoreThanGracePeriod(sponsoringOrganization))
{
_logger.LogWarning("Sponsoring Organization {SponsoringOrganizationId} is disabled for more than 3 months.", sponsoringOrganization.Id);
@ -113,7 +112,9 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo
return false;
}
if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgPlan.ProductTier)
var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier();
if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgProductTier)
{
_logger.LogWarning("Sponsoring Organization {SponsoringOrganizationId} is not on the required product type.", sponsoringOrganization.Id);
await CancelSponsorshipAsync(sponsoredOrganization, existingSponsorship);

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -31,9 +32,10 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
}
var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType;
var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier();
if (requiredSponsoringProductType == null ||
sponsoringOrg == null ||
StaticStore.GetPlan(sponsoringOrg.PlanType).ProductTier != requiredSponsoringProductType.Value)
sponsoringOrgProductTier != requiredSponsoringProductType.Value)
{
throw new BadRequestException("Specified Organization cannot sponsor other organizations.");
}

View File

@ -2,11 +2,11 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Services;
using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
@ -15,22 +15,25 @@ public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscripti
private readonly IPaymentService _paymentService;
private readonly IOrganizationService _organizationService;
private readonly IProviderRepository _providerRepository;
private readonly IPricingClient _pricingClient;
public AddSecretsManagerSubscriptionCommand(
IPaymentService paymentService,
IOrganizationService organizationService,
IProviderRepository providerRepository)
IProviderRepository providerRepository,
IPricingClient pricingClient)
{
_paymentService = paymentService;
_organizationService = organizationService;
_providerRepository = providerRepository;
_pricingClient = pricingClient;
}
public async Task SignUpAsync(Organization organization, int additionalSmSeats,
int additionalServiceAccounts)
{
await ValidateOrganization(organization);
var plan = StaticStore.GetPlan(organization.PlanType);
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var signup = SetOrganizationUpgrade(organization, additionalSmSeats, additionalServiceAccounts);
_organizationService.ValidateSecretsManagerPlan(plan, signup);
@ -73,7 +76,13 @@ public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscripti
throw new BadRequestException("Organization already uses Secrets Manager.");
}
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType && p.SupportsSecretsManager);
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.SupportsSecretsManager)
{
throw new BadRequestException("Organization's plan does not support Secrets Manager.");
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) && plan.ProductTier != ProductTierType.Free)
{
throw new BadRequestException("No payment method found.");

View File

@ -6,6 +6,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
@ -18,7 +19,6 @@ using Bit.Core.Services;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
@ -38,6 +38,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
private readonly IOrganizationService _organizationService;
private readonly IFeatureService _featureService;
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IPricingClient _pricingClient;
public UpgradeOrganizationPlanCommand(
IOrganizationUserRepository organizationUserRepository,
@ -53,7 +54,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IFeatureService featureService,
IOrganizationBillingService organizationBillingService)
IOrganizationBillingService organizationBillingService,
IPricingClient pricingClient)
{
_organizationUserRepository = organizationUserRepository;
_collectionRepository = collectionRepository;
@ -69,6 +71,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
_organizationService = organizationService;
_featureService = featureService;
_organizationBillingService = organizationBillingService;
_pricingClient = pricingClient;
}
public async Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade)
@ -84,14 +87,11 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
throw new BadRequestException("Your account has no payment method available.");
}
var existingPlan = StaticStore.GetPlan(organization.PlanType);
if (existingPlan == null)
{
throw new BadRequestException("Existing plan not found.");
}
var existingPlan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var newPlan = StaticStore.Plans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled);
if (newPlan == null)
var newPlan = await _pricingClient.GetPlanOrThrow(upgrade.Plan);
if (newPlan.Disabled)
{
throw new BadRequestException("Plan not found.");
}

View File

@ -7,6 +7,7 @@ using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Models.Api.Responses;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -37,6 +38,7 @@ public class StripePaymentService : IPaymentService
private readonly IFeatureService _featureService;
private readonly ITaxService _taxService;
private readonly ISubscriberService _subscriberService;
private readonly IPricingClient _pricingClient;
public StripePaymentService(
ITransactionRepository transactionRepository,
@ -46,7 +48,8 @@ public class StripePaymentService : IPaymentService
IGlobalSettings globalSettings,
IFeatureService featureService,
ITaxService taxService,
ISubscriberService subscriberService)
ISubscriberService subscriberService,
IPricingClient pricingClient)
{
_transactionRepository = transactionRepository;
_logger = logger;
@ -56,6 +59,7 @@ public class StripePaymentService : IPaymentService
_featureService = featureService;
_taxService = taxService;
_subscriberService = subscriberService;
_pricingClient = pricingClient;
}
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
@ -297,7 +301,7 @@ public class StripePaymentService : IPaymentService
OrganizationSponsorship sponsorship,
bool applySponsorship)
{
var existingPlan = Utilities.StaticStore.GetPlan(org.PlanType);
var existingPlan = await _pricingClient.GetPlanOrThrow(org.PlanType);
var sponsoredPlan = sponsorship?.PlanSponsorshipType != null ?
Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) :
null;
@ -887,18 +891,21 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret;
}
public Task<string> AdjustSubscription(
public async Task<string> AdjustSubscription(
Organization organization,
StaticStore.Plan updatedPlan,
int newlyPurchasedPasswordManagerSeats,
bool subscribedToSecretsManager,
int? newlyPurchasedSecretsManagerSeats,
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
int newlyPurchasedAdditionalStorage) =>
FinalizeSubscriptionChangeAsync(
int newlyPurchasedAdditionalStorage)
{
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
return await FinalizeSubscriptionChangeAsync(
organization,
new CompleteSubscriptionUpdate(
organization,
plan,
new SubscriptionData
{
Plan = updatedPlan,
@ -909,6 +916,7 @@ public class StripePaymentService : IPaymentService
newlyPurchasedAdditionalSecretsManagerServiceAccounts,
PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage
}), true);
}
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) =>
FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats));
@ -921,7 +929,7 @@ public class StripePaymentService : IPaymentService
=> FinalizeSubscriptionChangeAsync(
provider,
new ProviderSubscriptionUpdate(
plan.Type,
plan,
currentlySubscribedSeats,
newlySubscribedSeats));
@ -1957,7 +1965,7 @@ public class StripePaymentService : IPaymentService
string gatewayCustomerId,
string gatewaySubscriptionId)
{
var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan);
var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan);
var options = new InvoiceCreatePreviewOptions
{

View File

@ -1,5 +1,6 @@
using System.Collections.Immutable;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@ -137,6 +138,7 @@ public static class StaticStore
}
public static IDictionary<GlobalEquivalentDomainsType, IEnumerable<string>> GlobalDomains { get; set; }
[Obsolete("Use PricingClient.ListPlans to retrieve all plans.")]
public static IEnumerable<Plan> Plans { get; }
public static IEnumerable<SponsoredPlan> SponsoredPlans { get; set; } = new[]
{
@ -147,10 +149,11 @@ public static class StaticStore
SponsoringProductTierType = ProductTierType.Enterprise,
StripePlanId = "2021-family-for-enterprise-annually",
UsersCanSponsor = (OrganizationUserOrganizationDetails org) =>
GetPlan(org.PlanType).ProductTier == ProductTierType.Enterprise,
org.PlanType.GetProductTier() == ProductTierType.Enterprise,
}
};
[Obsolete("Use PricingClient.GetPlan to retrieve a plan.")]
public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType);
public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) =>
@ -167,6 +170,7 @@ public static class StaticStore
/// </returns>
public static bool IsAddonSubscriptionItem(string stripePlanId)
{
// TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-16844
return Plans.Any(p =>
p.PasswordManager.StripeStoragePlanId == stripePlanId ||
(p.SecretsManager?.StripeServiceAccountPlanId == stripePlanId));