mirror of
https://github.com/bitwarden/server.git
synced 2025-07-04 01:22:50 -05:00
[PM-13837] Switch provider price IDs (#5518)
* Add ProviderPriceAdapter This is a temporary utility that will be used to manage retrieval of provider price IDs until all providers can be migrated to the new price structure. * Updated ProviderBillingService.ChangePlan * Update ProviderBillingService.SetupSubscription * Update ProviderBillingService.UpdateSeatMinimums * Update ProviderBillingService.CurrySeatScalingUpdate * Mark StripeProviderPortalSeatPlanId obsolete * Run dotnet format
This commit is contained in:
@ -35,7 +35,6 @@ public class ProviderBillingService(
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
IPricingClient pricingClient,
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
@ -148,36 +147,29 @@ public class ProviderBillingService(
|
||||
|
||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||
{
|
||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||
var (provider, providerPlanId, newPlanType) = command;
|
||||
|
||||
if (plan == null)
|
||||
var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId);
|
||||
|
||||
if (providerPlan == null)
|
||||
{
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
if (plan.PlanType == command.NewPlan)
|
||||
if (providerPlan.PlanType == newPlanType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
|
||||
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
plan.PlanType = command.NewPlan;
|
||||
await providerPlanRepository.ReplaceAsync(plan);
|
||||
var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
|
||||
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw new ConflictException("Subscription not found.");
|
||||
}
|
||||
providerPlan.PlanType = newPlanType;
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
@ -185,7 +177,7 @@ public class ProviderBillingService(
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Price = newPriceId,
|
||||
Quantity = oldSubscriptionItem!.Quantity
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
@ -196,12 +188,14 @@ public class ProviderBillingService(
|
||||
]
|
||||
};
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions);
|
||||
|
||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||
// 2. Assign PlanType & PlanName to Organization
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
|
||||
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
|
||||
|
||||
foreach (var providerOrganization in providerOrganizations)
|
||||
{
|
||||
@ -210,8 +204,8 @@ public class ProviderBillingService(
|
||||
{
|
||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||
}
|
||||
organization.PlanType = command.NewPlan;
|
||||
organization.Plan = newPlanConfiguration.Name;
|
||||
organization.PlanType = newPlanType;
|
||||
organization.Plan = newPlan.Name;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
}
|
||||
@ -405,7 +399,7 @@ public class ProviderBillingService(
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
var update = CurrySeatScalingUpdate(
|
||||
var scaleQuantityTo = CurrySeatScalingUpdate(
|
||||
provider,
|
||||
providerPlan,
|
||||
newlyAssignedSeatTotal);
|
||||
@ -428,9 +422,7 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||
newlyAssignedSeatTotal > seatMinimum)
|
||||
{
|
||||
await update(
|
||||
seatMinimum,
|
||||
newlyAssignedSeatTotal);
|
||||
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||
}
|
||||
/*
|
||||
* Above the limit => Above the limit:
|
||||
@ -439,9 +431,7 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||
newlyAssignedSeatTotal > seatMinimum)
|
||||
{
|
||||
await update(
|
||||
currentlyAssignedSeatTotal,
|
||||
newlyAssignedSeatTotal);
|
||||
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||
}
|
||||
/*
|
||||
* Above the limit => Below the limit:
|
||||
@ -450,9 +440,7 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||
newlyAssignedSeatTotal <= seatMinimum)
|
||||
{
|
||||
await update(
|
||||
currentlyAssignedSeatTotal,
|
||||
seatMinimum);
|
||||
await scaleQuantityTo(seatMinimum);
|
||||
}
|
||||
}
|
||||
|
||||
@ -586,9 +574,11 @@ public class ProviderBillingService(
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Price = priceId,
|
||||
Quantity = providerPlan.SeatMinimum
|
||||
});
|
||||
}
|
||||
@ -654,43 +644,37 @@ public class ProviderBillingService(
|
||||
|
||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||
{
|
||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||
var (provider, updatedPlanConfigurations) = command;
|
||||
|
||||
if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0))
|
||||
{
|
||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||
}
|
||||
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw new ConflictException("Subscription not found.");
|
||||
}
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
foreach (var newPlanConfiguration in command.Configuration)
|
||||
foreach (var updatedPlanConfiguration in updatedPlanConfigurations)
|
||||
{
|
||||
var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration;
|
||||
|
||||
var providerPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType);
|
||||
|
||||
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
|
||||
if (providerPlan.SeatMinimum != updatedSeatMinimum)
|
||||
{
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
|
||||
|
||||
var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
|
||||
|
||||
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
if (providerPlan.PurchasedSeats == 0)
|
||||
{
|
||||
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
|
||||
if (providerPlan.AllocatedSeats > updatedSeatMinimum)
|
||||
{
|
||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
|
||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum;
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
@ -705,7 +689,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = newPlanConfiguration.SeatsMinimum
|
||||
Quantity = updatedSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -713,9 +697,9 @@ public class ProviderBillingService(
|
||||
{
|
||||
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||
|
||||
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
|
||||
if (updatedSeatMinimum <= totalSeats)
|
||||
{
|
||||
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
|
||||
providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -724,12 +708,12 @@ public class ProviderBillingService(
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = newPlanConfiguration.SeatsMinimum
|
||||
Quantity = updatedSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
|
||||
providerPlan.SeatMinimum = updatedSeatMinimum;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
@ -737,23 +721,33 @@ public class ProviderBillingService(
|
||||
|
||||
if (subscriptionItemOptionsList.Count > 0)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||
}
|
||||
}
|
||||
|
||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
||||
private Func<int, Task> CurrySeatScalingUpdate(
|
||||
Provider provider,
|
||||
ProviderPlan providerPlan,
|
||||
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
|
||||
int newlyAssignedSeats) => async newlySubscribedSeats =>
|
||||
{
|
||||
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
await paymentService.AdjustSeats(
|
||||
provider,
|
||||
plan,
|
||||
currentlySubscribedSeats,
|
||||
newlySubscribedSeats);
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
|
||||
var item = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Price = priceId,
|
||||
Quantity = newlySubscribedSeats
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
|
||||
? newlySubscribedSeats - providerPlan.SeatMinimum
|
||||
|
@ -0,0 +1,133 @@
|
||||
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing;
|
||||
|
||||
public static class ProviderPriceAdapter
|
||||
{
|
||||
public static class MSP
|
||||
{
|
||||
public static class Active
|
||||
{
|
||||
public const string Enterprise = "provider-portal-enterprise-monthly-2025";
|
||||
public const string Teams = "provider-portal-teams-monthly-2025";
|
||||
}
|
||||
|
||||
public static class Legacy
|
||||
{
|
||||
public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||
public const string Teams = "password-manager-provider-portal-teams-monthly-2024";
|
||||
public static readonly List<string> List = [Enterprise, Teams];
|
||||
}
|
||||
}
|
||||
|
||||
public static class BusinessUnit
|
||||
{
|
||||
public static class Active
|
||||
{
|
||||
public const string Annually = "business-unit-portal-enterprise-annually-2025";
|
||||
public const string Monthly = "business-unit-portal-enterprise-monthly-2025";
|
||||
}
|
||||
|
||||
public static class Legacy
|
||||
{
|
||||
public const string Annually = "password-manager-provider-portal-enterprise-annually-2024";
|
||||
public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||
public static readonly List<string> List = [Annually, Monthly];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> and <paramref name="subscription"/> to determine
|
||||
/// whether the <paramref name="provider"/> is on active or legacy pricing and then returns a Stripe price ID for the provided
|
||||
/// <paramref name="planType"/> based on that determination.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||
/// <param name="subscription">The provider's subscription.</param>
|
||||
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||
public static string GetPriceId(
|
||||
Provider provider,
|
||||
Subscription subscription,
|
||||
PlanType planType)
|
||||
{
|
||||
var priceIds = subscription.Items.Select(item => item.Price.Id);
|
||||
|
||||
var invalidPlanType =
|
||||
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||
|
||||
return provider.Type switch
|
||||
{
|
||||
ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any()
|
||||
? planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => MSP.Legacy.Teams,
|
||||
PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
}
|
||||
: planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||
? planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly,
|
||||
_ => throw invalidPlanType
|
||||
}
|
||||
: planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
_ => throw new BillingException(
|
||||
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> to return the active Stripe price ID for the provided
|
||||
/// <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||
public static string GetActivePriceId(
|
||||
Provider provider,
|
||||
PlanType planType)
|
||||
{
|
||||
var invalidPlanType =
|
||||
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||
|
||||
return provider.Type switch
|
||||
{
|
||||
ProviderType.Msp => planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
ProviderType.MultiOrganizationEnterprise => planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
_ => throw new BillingException(
|
||||
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user