[PM-26793] Fetch premium plan from pricing service (#6450)

* Fetch premium plan from pricing service

* Run dotnet format
This commit is contained in:
Alex Morask 2025-10-22 14:13:16 -05:00 committed by GitHub
parent 0a7e6ae3ca
commit 6a3fc08957
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 191 additions and 63 deletions

View File

@ -3,7 +3,7 @@ using Bit.Core.Billing.Pricing;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers; namespace Bit.Api.Billing.Controllers;
[Route("plans")] [Route("plans")]
[Authorize("Web")] [Authorize("Web")]
@ -18,4 +18,11 @@ public class PlansController(
var responses = plans.Select(plan => new PlanResponseModel(plan)); var responses = plans.Select(plan => new PlanResponseModel(plan));
return new ListResponseModel<PlanResponseModel>(responses); return new ListResponseModel<PlanResponseModel>(responses);
} }
[HttpGet("premium")]
public async Task<IResult> GetPremiumPlanAsync()
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
return TypedResults.Ok(premiumPlan);
}
} }

View File

@ -3,6 +3,7 @@ using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -50,7 +51,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ISubscriberService subscriberService, ISubscriberService subscriberService,
IUserService userService, IUserService userService,
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger) ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger,
IPricingClient pricingClient)
: BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand : BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand
{ {
private static readonly List<string> _expand = ["tax"]; private static readonly List<string> _expand = ["tax"];
@ -255,11 +257,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Customer customer, Customer customer,
int? storage) int? storage)
{ {
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions> var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{ {
new () new ()
{ {
Price = StripeConstants.Prices.PremiumAnnually, Price = premiumPlan.Seat.StripePriceId,
Quantity = 1 Quantity = 1
} }
}; };
@ -268,7 +272,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = StripeConstants.Prices.StoragePlanPersonal, Price = premiumPlan.Storage.StripePriceId,
Quantity = storage Quantity = storage
}); });
} }

View File

@ -1,14 +1,12 @@
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
namespace Bit.Core.Billing.Premium.Commands; namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
public interface IPreviewPremiumTaxCommand public interface IPreviewPremiumTaxCommand
{ {
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run( Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
@ -18,6 +16,7 @@ public interface IPreviewPremiumTaxCommand
public class PreviewPremiumTaxCommand( public class PreviewPremiumTaxCommand(
ILogger<PreviewPremiumTaxCommand> logger, ILogger<PreviewPremiumTaxCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : BaseBillingCommand<PreviewPremiumTaxCommand>(logger), IPreviewPremiumTaxCommand IStripeAdapter stripeAdapter) : BaseBillingCommand<PreviewPremiumTaxCommand>(logger), IPreviewPremiumTaxCommand
{ {
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run( public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
@ -25,6 +24,8 @@ public class PreviewPremiumTaxCommand(
BillingAddress billingAddress) BillingAddress billingAddress)
=> HandleAsync<(decimal, decimal)>(async () => => HandleAsync<(decimal, decimal)>(async () =>
{ {
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var options = new InvoiceCreatePreviewOptions var options = new InvoiceCreatePreviewOptions
{ {
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
@ -41,7 +42,7 @@ public class PreviewPremiumTaxCommand(
{ {
Items = Items =
[ [
new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 }
] ]
} }
}; };
@ -50,7 +51,7 @@ public class PreviewPremiumTaxCommand(
{ {
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{ {
Price = Prices.StoragePlanPersonal, Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorage Quantity = additionalStorage
}); });
} }

View File

@ -3,12 +3,14 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.StaticStore; using Bit.Core.Models.StaticStore;
using Bit.Core.Utilities; using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Pricing; namespace Bit.Core.Billing.Pricing;
using OrganizationPlan = Plan;
using PremiumPlan = Premium.Plan;
public interface IPricingClient public interface IPricingClient
{ {
// TODO: Rename with Organization focus.
/// <summary> /// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled, /// 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"/>. /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
@ -16,8 +18,9 @@ public interface IPricingClient
/// <param name="planType">The type of plan to retrieve.</param> /// <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> /// <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> /// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan?> GetPlan(PlanType planType); Task<OrganizationPlan?> GetPlan(PlanType planType);
// TODO: Rename with Organization focus.
/// <summary> /// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled, /// 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"/>. /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
@ -26,13 +29,17 @@ public interface IPricingClient
/// <returns>A Bitwarden <see cref="Plan"/> record.</returns> /// <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="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> /// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan> GetPlanOrThrow(PlanType planType); Task<OrganizationPlan> GetPlanOrThrow(PlanType planType);
// TODO: Rename with Organization focus.
/// <summary> /// <summary>
/// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled, /// 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"/>. /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
/// </summary> /// </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> /// <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> /// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<List<Plan>> ListPlans(); Task<List<OrganizationPlan>> ListPlans();
Task<PremiumPlan> GetAvailablePremiumPlan();
Task<List<PremiumPlan>> ListPremiumPlans();
} }

View File

@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Pricing.Models; namespace Bit.Core.Billing.Pricing.Organizations;
public class Feature public class Feature
{ {

View File

@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Pricing.Models; namespace Bit.Core.Billing.Pricing.Organizations;
public class Plan public class Plan
{ {

View File

@ -1,8 +1,6 @@
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing.Models;
using Plan = Bit.Core.Billing.Pricing.Models.Plan;
namespace Bit.Core.Billing.Pricing; namespace Bit.Core.Billing.Pricing.Organizations;
public record PlanAdapter : Core.Models.StaticStore.Plan public record PlanAdapter : Core.Models.StaticStore.Plan
{ {

View File

@ -2,7 +2,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using OneOf; using OneOf;
namespace Bit.Core.Billing.Pricing.Models; namespace Bit.Core.Billing.Pricing.Organizations;
[JsonConverter(typeof(PurchasableJsonConverter))] [JsonConverter(typeof(PurchasableJsonConverter))]
public class Purchasable(OneOf<Free, Packaged, Scalable> input) : OneOfBase<Free, Packaged, Scalable>(input) public class Purchasable(OneOf<Free, Packaged, Scalable> input) : OneOfBase<Free, Packaged, Scalable>(input)

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Billing.Pricing.Premium;
public class Plan
{
public string Name { get; init; } = null!;
public int? LegacyYear { get; init; }
public bool Available { get; init; }
public Purchasable Seat { get; init; } = null!;
public Purchasable Storage { get; init; } = null!;
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Billing.Pricing.Premium;
public class Purchasable
{
public string StripePriceId { get; init; } = null!;
public decimal Price { get; init; }
}

View File

@ -1,24 +1,27 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing.Organizations;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Plan = Bit.Core.Models.StaticStore.Plan;
#nullable enable
namespace Bit.Core.Billing.Pricing; namespace Bit.Core.Billing.Pricing;
using OrganizationPlan = Bit.Core.Models.StaticStore.Plan;
using PremiumPlan = Premium.Plan;
using Purchasable = Premium.Purchasable;
public class PricingClient( public class PricingClient(
IFeatureService featureService, IFeatureService featureService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
HttpClient httpClient, HttpClient httpClient,
ILogger<PricingClient> logger) : IPricingClient ILogger<PricingClient> logger) : IPricingClient
{ {
public async Task<Plan?> GetPlan(PlanType planType) public async Task<OrganizationPlan?> GetPlan(PlanType planType)
{ {
if (globalSettings.SelfHosted) if (globalSettings.SelfHosted)
{ {
@ -40,16 +43,14 @@ public class PricingClient(
return null; return null;
} }
var response = await httpClient.GetAsync($"plans/lookup/{lookupKey}"); var response = await httpClient.GetAsync($"plans/organization/{lookupKey}");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var plan = await response.Content.ReadFromJsonAsync<Models.Plan>(); var plan = await response.Content.ReadFromJsonAsync<Plan>();
if (plan == null) return plan == null
{ ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); : new PlanAdapter(plan);
}
return new PlanAdapter(plan);
} }
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
@ -62,19 +63,14 @@ public class PricingClient(
message: $"Request to the Pricing Service failed with status code {response.StatusCode}"); message: $"Request to the Pricing Service failed with status code {response.StatusCode}");
} }
public async Task<Plan> GetPlanOrThrow(PlanType planType) public async Task<OrganizationPlan> GetPlanOrThrow(PlanType planType)
{ {
var plan = await GetPlan(planType); var plan = await GetPlan(planType);
if (plan == null) return plan ?? throw new NotFoundException($"Could not find plan for type {planType}");
{
throw new NotFoundException();
} }
return plan; public async Task<List<OrganizationPlan>> ListPlans()
}
public async Task<List<Plan>> ListPlans()
{ {
if (globalSettings.SelfHosted) if (globalSettings.SelfHosted)
{ {
@ -88,16 +84,51 @@ public class PricingClient(
return StaticStore.Plans.ToList(); return StaticStore.Plans.ToList();
} }
var response = await httpClient.GetAsync("plans"); var response = await httpClient.GetAsync("plans/organization");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var plans = await response.Content.ReadFromJsonAsync<List<Models.Plan>>(); var plans = await response.Content.ReadFromJsonAsync<List<Plan>>();
if (plans == null) return plans == null
{ ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); : plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList();
} }
return plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList();
throw new BillingException(
message: $"Request to the Pricing Service failed with status {response.StatusCode}");
}
public async Task<PremiumPlan> GetAvailablePremiumPlan()
{
var premiumPlans = await ListPremiumPlans();
var availablePlan = premiumPlans.FirstOrDefault(premiumPlan => premiumPlan.Available);
return availablePlan ?? throw new NotFoundException("Could not find available premium plan");
}
public async Task<List<PremiumPlan>> ListPremiumPlans()
{
if (globalSettings.SelfHosted)
{
return [];
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
var fetchPremiumPriceFromPricingService =
featureService.IsEnabled(FeatureFlagKeys.PM26793_FetchPremiumPriceFromPricingService);
if (!usePricingService || !fetchPremiumPriceFromPricingService)
{
return [CurrentPremiumPlan];
}
var response = await httpClient.GetAsync("plans/premium");
if (response.IsSuccessStatusCode)
{
var plans = await response.Content.ReadFromJsonAsync<List<PremiumPlan>>();
return plans ?? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null");
} }
throw new BillingException( throw new BillingException(
@ -130,4 +161,13 @@ public class PricingClient(
PlanType.TeamsStarter2023 => "teams-starter-2023", PlanType.TeamsStarter2023 => "teams-starter-2023",
_ => null _ => null
}; };
private static PremiumPlan CurrentPremiumPlan => new()
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
};
} }

View File

@ -6,6 +6,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -30,7 +31,8 @@ public class PremiumUserBillingService(
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IUserRepository userRepository) : IPremiumUserBillingService IUserRepository userRepository,
IPricingClient pricingClient) : IPremiumUserBillingService
{ {
public async Task Credit(User user, decimal amount) public async Task Credit(User user, decimal amount)
{ {
@ -301,11 +303,13 @@ public class PremiumUserBillingService(
Customer customer, Customer customer,
int? storage) int? storage)
{ {
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions> var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{ {
new () new ()
{ {
Price = StripeConstants.Prices.PremiumAnnually, Price = premiumPlan.Seat.StripePriceId,
Quantity = 1 Quantity = 1
} }
}; };
@ -314,7 +318,7 @@ public class PremiumUserBillingService(
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = StripeConstants.Prices.StoragePlanPersonal, Price = premiumPlan.Storage.StripePriceId,
Quantity = storage Quantity = storage
}); });
} }

View File

@ -185,6 +185,7 @@ public static class FeatureFlagKeys
public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button";
public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog";
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
/* Key Management Team */ /* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Responses;
@ -896,11 +897,14 @@ public class StripePaymentService : IPaymentService
} }
} }
[Obsolete($"Use {nameof(PreviewPremiumTaxCommand)} instead.")]
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync( public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
PreviewIndividualInvoiceRequestBody parameters, PreviewIndividualInvoiceRequestBody parameters,
string gatewayCustomerId, string gatewayCustomerId,
string gatewaySubscriptionId) string gatewaySubscriptionId)
{ {
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
var options = new InvoiceCreatePreviewOptions var options = new InvoiceCreatePreviewOptions
{ {
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, },
@ -909,8 +913,17 @@ public class StripePaymentService : IPaymentService
{ {
Items = Items =
[ [
new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = StripeConstants.Prices.PremiumAnnually }, new InvoiceSubscriptionDetailsItemOptions
new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = StripeConstants.Prices.StoragePlanPersonal } {
Quantity = 1,
Plan = premiumPlan.Seat.StripePriceId
},
new InvoiceSubscriptionDetailsItemOptions
{
Quantity = parameters.PasswordManager.AdditionalStorage,
Plan = premiumPlan.Storage.StripePriceId
}
] ]
}, },
CustomerDetails = new InvoiceCustomerDetailsOptions CustomerDetails = new InvoiceCustomerDetailsOptions
@ -1028,7 +1041,7 @@ public class StripePaymentService : IPaymentService
{ {
Items = Items =
[ [
new() new InvoiceSubscriptionDetailsItemOptions
{ {
Quantity = parameters.PasswordManager.AdditionalStorage, Quantity = parameters.PasswordManager.AdditionalStorage,
Plan = plan.PasswordManager.StripeStoragePlanId Plan = plan.PasswordManager.StripeStoragePlanId
@ -1049,7 +1062,7 @@ public class StripePaymentService : IPaymentService
{ {
var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value); var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value);
options.SubscriptionDetails.Items.Add( options.SubscriptionDetails.Items.Add(
new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId } new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId }
); );
} }
else else
@ -1057,13 +1070,13 @@ public class StripePaymentService : IPaymentService
if (plan.PasswordManager.HasAdditionalSeatsOption) if (plan.PasswordManager.HasAdditionalSeatsOption)
{ {
options.SubscriptionDetails.Items.Add( options.SubscriptionDetails.Items.Add(
new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId }
); );
} }
else else
{ {
options.SubscriptionDetails.Items.Add( options.SubscriptionDetails.Items.Add(
new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = plan.PasswordManager.StripePlanId }
); );
} }
@ -1071,7 +1084,7 @@ public class StripePaymentService : IPaymentService
{ {
if (plan.SecretsManager.HasAdditionalSeatsOption) if (plan.SecretsManager.HasAdditionalSeatsOption)
{ {
options.SubscriptionDetails.Items.Add(new() options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{ {
Quantity = parameters.SecretsManager?.Seats ?? 0, Quantity = parameters.SecretsManager?.Seats ?? 0,
Plan = plan.SecretsManager.StripeSeatPlanId Plan = plan.SecretsManager.StripeSeatPlanId
@ -1080,7 +1093,7 @@ public class StripePaymentService : IPaymentService
if (plan.SecretsManager.HasAdditionalServiceAccountOption) if (plan.SecretsManager.HasAdditionalServiceAccountOption)
{ {
options.SubscriptionDetails.Items.Add(new() options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{ {
Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0,
Plan = plan.SecretsManager.StripeServiceAccountPlanId Plan = plan.SecretsManager.StripeServiceAccountPlanId

View File

@ -14,10 +14,10 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models; using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context; using Bit.Core.Context;
@ -72,6 +72,7 @@ public class UserService : UserManager<User>, IUserService
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IDistributedCache _distributedCache; private readonly IDistributedCache _distributedCache;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
public UserService( public UserService(
IUserRepository userRepository, IUserRepository userRepository,
@ -106,7 +107,8 @@ public class UserService : UserManager<User>, IUserService
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IDistributedCache distributedCache, IDistributedCache distributedCache,
IPolicyRequirementQuery policyRequirementQuery) IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient)
: base( : base(
store, store,
optionsAccessor, optionsAccessor,
@ -146,6 +148,7 @@ public class UserService : UserManager<User>, IUserService
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_distributedCache = distributedCache; _distributedCache = distributedCache;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
} }
public Guid? GetProperUserId(ClaimsPrincipal principal) public Guid? GetProperUserId(ClaimsPrincipal principal)
@ -972,8 +975,9 @@ public class UserService : UserManager<User>, IUserService
throw new BadRequestException("Not a premium user."); throw new BadRequestException("Not a premium user.");
} }
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
StripeConstants.Prices.StoragePlanPersonal);
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId);
await SaveUserAsync(user); await SaveUserAsync(user);
return secret; return secret;
} }

View File

@ -1,7 +1,9 @@
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
@ -14,6 +16,8 @@ using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
using Address = Stripe.Address; using Address = Stripe.Address;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
using StripeCustomer = Stripe.Customer; using StripeCustomer = Stripe.Customer;
using StripeSubscription = Stripe.Subscription; using StripeSubscription = Stripe.Subscription;
@ -28,6 +32,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>(); private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly IUserService _userService = Substitute.For<IUserService>(); private readonly IUserService _userService = Substitute.For<IUserService>();
private readonly IPushNotificationService _pushNotificationService = Substitute.For<IPushNotificationService>(); private readonly IPushNotificationService _pushNotificationService = Substitute.For<IPushNotificationService>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly CreatePremiumCloudHostedSubscriptionCommand _command; private readonly CreatePremiumCloudHostedSubscriptionCommand _command;
public CreatePremiumCloudHostedSubscriptionCommandTests() public CreatePremiumCloudHostedSubscriptionCommandTests()
@ -36,6 +41,17 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
baseServiceUri.CloudRegion.Returns("US"); baseServiceUri.CloudRegion.Returns("US");
_globalSettings.BaseServiceUri.Returns(baseServiceUri); _globalSettings.BaseServiceUri.Returns(baseServiceUri);
// Setup default premium plan with standard pricing
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
_command = new CreatePremiumCloudHostedSubscriptionCommand( _command = new CreatePremiumCloudHostedSubscriptionCommand(
_braintreeGateway, _braintreeGateway,
_globalSettings, _globalSettings,
@ -44,7 +60,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
_subscriberService, _subscriberService,
_userService, _userService,
_pushNotificationService, _pushNotificationService,
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>()); Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>(),
_pricingClient);
} }
[Theory, BitAutoData] [Theory, BitAutoData]

View File

@ -1,23 +1,38 @@
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
using static Bit.Core.Billing.Constants.StripeConstants; using static Bit.Core.Billing.Constants.StripeConstants;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
namespace Bit.Core.Test.Billing.Premium.Commands; namespace Bit.Core.Test.Billing.Premium.Commands;
public class PreviewPremiumTaxCommandTests public class PreviewPremiumTaxCommandTests
{ {
private readonly ILogger<PreviewPremiumTaxCommand> _logger = Substitute.For<ILogger<PreviewPremiumTaxCommand>>(); private readonly ILogger<PreviewPremiumTaxCommand> _logger = Substitute.For<ILogger<PreviewPremiumTaxCommand>>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>(); private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly PreviewPremiumTaxCommand _command; private readonly PreviewPremiumTaxCommand _command;
public PreviewPremiumTaxCommandTests() public PreviewPremiumTaxCommandTests()
{ {
_command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter); // Setup default premium plan with standard pricing
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
_command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter);
} }
[Fact] [Fact]