1
0
mirror of https://github.com/bitwarden/server synced 2025-12-30 23:23:37 +00:00

[PM-21421] Support legacy > current plan transition when resubscribing (#6728)

* Refactor RestartSubscriptionCommand to support legacy > modern plan transition

* Run dotnet format

* Claude feedback

* Claude feedback
This commit is contained in:
Alex Morask
2025-12-18 09:12:16 -06:00
committed by GitHub
parent d03277323f
commit 982957a2be
2 changed files with 706 additions and 139 deletions

View File

@@ -1,12 +1,13 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
@@ -21,14 +22,14 @@ public interface IRestartSubscriptionCommand
}
public class RestartSubscriptionCommand(
ILogger<RestartSubscriptionCommand> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserRepository userRepository) : IRestartSubscriptionCommand
ISubscriberService subscriberService) : BaseBillingCommand<RestartSubscriptionCommand>(logger), IRestartSubscriptionCommand
{
public async Task<BillingCommandResult<None>> Run(
ISubscriber subscriber)
public Task<BillingCommandResult<None>> Run(
ISubscriber subscriber) => HandleAsync<None>(async () =>
{
var existingSubscription = await subscriberService.GetSubscription(subscriber);
@@ -37,56 +38,147 @@ public class RestartSubscriptionCommand(
return new BadRequest("Cannot restart a subscription that is not canceled.");
}
await RestartSubscriptionAsync(subscriber, existingSubscription);
return new None();
});
private Task RestartSubscriptionAsync(
ISubscriber subscriber,
Subscription canceledSubscription) => subscriber switch
{
Organization organization => RestartOrganizationSubscriptionAsync(organization, canceledSubscription),
_ => throw new NotSupportedException("Only organization subscriptions can be restarted")
};
private async Task RestartOrganizationSubscriptionAsync(
Organization organization,
Subscription canceledSubscription)
{
var plans = await pricingClient.ListPlans();
var oldPlan = plans.FirstOrDefault(plan => plan.Type == organization.PlanType);
if (oldPlan == null)
{
throw new ConflictException("Could not find plan for organization's plan type");
}
var newPlan = oldPlan.Disabled
? plans.FirstOrDefault(plan =>
plan.ProductTier == oldPlan.ProductTier &&
plan.IsAnnual == oldPlan.IsAnnual &&
!plan.Disabled)
: oldPlan;
if (newPlan == null)
{
throw new ConflictException("Could not find the current, enabled plan for organization's tier and cadence");
}
if (newPlan.Type != oldPlan.Type)
{
organization.PlanType = newPlan.Type;
organization.Plan = newPlan.Name;
organization.SelfHost = newPlan.HasSelfHost;
organization.UsePolicies = newPlan.HasPolicies;
organization.UseGroups = newPlan.HasGroups;
organization.UseDirectory = newPlan.HasDirectory;
organization.UseEvents = newPlan.HasEvents;
organization.UseTotp = newPlan.HasTotp;
organization.Use2fa = newPlan.Has2fa;
organization.UseApi = newPlan.HasApi;
organization.UseSso = newPlan.HasSso;
organization.UseOrganizationDomains = newPlan.HasOrganizationDomains;
organization.UseKeyConnector = newPlan.HasKeyConnector;
organization.UseScim = newPlan.HasScim;
organization.UseResetPassword = newPlan.HasResetPassword;
organization.UsersGetPremium = newPlan.UsersGetPremium;
organization.UseCustomPermissions = newPlan.HasCustomPermissions;
}
var items = new List<SubscriptionItemOptions>();
// Password Manager
var passwordManagerItem = canceledSubscription.Items.FirstOrDefault(item =>
item.Price.Id == (oldPlan.HasNonSeatBasedPasswordManagerPlan()
? oldPlan.PasswordManager.StripePlanId
: oldPlan.PasswordManager.StripeSeatPlanId));
if (passwordManagerItem == null)
{
throw new ConflictException("Organization's subscription does not have a Password Manager subscription item.");
}
items.Add(new SubscriptionItemOptions
{
Price = newPlan.HasNonSeatBasedPasswordManagerPlan() ? newPlan.PasswordManager.StripePlanId : newPlan.PasswordManager.StripeSeatPlanId,
Quantity = passwordManagerItem.Quantity
});
// Storage
var storageItem = canceledSubscription.Items.FirstOrDefault(
item => item.Price.Id == oldPlan.PasswordManager.StripeStoragePlanId);
if (storageItem != null)
{
items.Add(new SubscriptionItemOptions
{
Price = newPlan.PasswordManager.StripeStoragePlanId,
Quantity = storageItem.Quantity
});
}
// Secrets Manager & Service Accounts
var secretsManagerItem = oldPlan.SecretsManager != null
? canceledSubscription.Items.FirstOrDefault(item =>
item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId)
: null;
var serviceAccountsItem = oldPlan.SecretsManager != null
? canceledSubscription.Items.FirstOrDefault(item =>
item.Price.Id == oldPlan.SecretsManager.StripeServiceAccountPlanId)
: null;
if (newPlan.SecretsManager != null)
{
if (secretsManagerItem != null)
{
items.Add(new SubscriptionItemOptions
{
Price = newPlan.SecretsManager.StripeSeatPlanId,
Quantity = secretsManagerItem.Quantity
});
}
if (serviceAccountsItem != null)
{
items.Add(new SubscriptionItemOptions
{
Price = newPlan.SecretsManager.StripeServiceAccountPlanId,
Quantity = serviceAccountsItem.Quantity
});
}
}
var options = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
CollectionMethod = CollectionMethod.ChargeAutomatically,
Customer = existingSubscription.CustomerId,
Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions
{
Price = subscriptionItem.Price.Id,
Quantity = subscriptionItem.Quantity
}).ToList(),
Metadata = existingSubscription.Metadata,
Customer = canceledSubscription.CustomerId,
Items = items,
Metadata = canceledSubscription.Metadata,
OffSession = true,
TrialPeriodDays = 0
};
var subscription = await stripeAdapter.CreateSubscriptionAsync(options);
await EnableAsync(subscriber, subscription);
return new None();
}
private async Task EnableAsync(ISubscriber subscriber, Subscription subscription)
{
switch (subscriber)
{
case Organization organization:
{
organization.GatewaySubscriptionId = subscription.Id;
organization.Enabled = true;
organization.ExpirationDate = subscription.GetCurrentPeriodEnd();
organization.RevisionDate = DateTime.UtcNow;
await organizationRepository.ReplaceAsync(organization);
break;
}
case Provider provider:
{
provider.GatewaySubscriptionId = subscription.Id;
provider.Enabled = true;
provider.RevisionDate = DateTime.UtcNow;
await providerRepository.ReplaceAsync(provider);
break;
}
case User user:
{
user.GatewaySubscriptionId = subscription.Id;
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
user.RevisionDate = DateTime.UtcNow;
await userRepository.ReplaceAsync(user);
break;
}
}
organization.GatewaySubscriptionId = subscription.Id;
organization.Enabled = true;
organization.ExpirationDate = subscription.GetCurrentPeriodEnd();
organization.RevisionDate = DateTime.UtcNow;
await organizationRepository.ReplaceAsync(organization);
}
}

View File

@@ -1,11 +1,14 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Test.Billing.Mocks;
using NSubstitute;
using Stripe;
using Xunit;
@@ -17,20 +20,19 @@ using static StripeConstants;
public class RestartSubscriptionCommandTests
{
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private readonly RestartSubscriptionCommand _command;
public RestartSubscriptionCommandTests()
{
_command = new RestartSubscriptionCommand(
Substitute.For<Microsoft.Extensions.Logging.ILogger<RestartSubscriptionCommand>>(),
_organizationRepository,
_providerRepository,
_pricingClient,
_stripeAdapter,
_subscriberService,
_userRepository);
_subscriberService);
}
[Fact]
@@ -63,11 +65,56 @@ public class RestartSubscriptionCommandTests
}
[Fact]
public async Task Run_Organization_Success_ReturnsNone()
public async Task Run_Provider_ReturnsUnhandledWithNotSupportedException()
{
var provider = new Provider { Id = Guid.NewGuid() };
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_123"
};
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
var result = await _command.Run(provider);
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.IsType<NotSupportedException>(unhandled.Exception);
}
[Fact]
public async Task Run_User_ReturnsUnhandledWithNotSupportedException()
{
var user = new User { Id = Guid.NewGuid() };
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_123"
};
_subscriberService.GetSubscription(user).Returns(existingSubscription);
var result = await _command.Run(user);
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.IsType<NotSupportedException>(unhandled.Exception);
}
[Fact]
public async Task Run_Organization_MissingPasswordManagerItem_ReturnsUnhandledWithConflictException()
{
var organizationId = Guid.NewGuid();
var organization = new Organization { Id = organizationId };
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually
};
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
var existingSubscription = new Subscription
{
@@ -77,11 +124,122 @@ public class RestartSubscriptionCommandTests
{
Data =
[
new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 },
new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 }
new SubscriptionItem { Price = new Price { Id = "some-other-price-id" }, Quantity = 10 }
]
},
Metadata = new Dictionary<string, string> { ["key"] = "value" }
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([plan]);
var result = await _command.Run(organization);
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.IsType<ConflictException>(unhandled.Exception);
Assert.Equal("Organization's subscription does not have a Password Manager subscription item.", unhandled.Exception.Message);
}
[Fact]
public async Task Run_Organization_PlanNotFound_ReturnsUnhandledWithConflictException()
{
var organizationId = Guid.NewGuid();
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually
};
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = "some-price-id" }, Quantity = 10 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
// Return a plan list that doesn't contain the organization's plan type
_pricingClient.ListPlans().Returns([MockPlans.Get(PlanType.TeamsAnnually)]);
var result = await _command.Run(organization);
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.IsType<ConflictException>(unhandled.Exception);
Assert.Equal("Could not find plan for organization's plan type", unhandled.Exception.Message);
}
[Fact]
public async Task Run_Organization_DisabledPlanWithNoEnabledReplacement_ReturnsUnhandledWithConflictException()
{
var organizationId = Guid.NewGuid();
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually2023
};
var oldPlan = new DisabledEnterprisePlan2023(true);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_old",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
// Return only the disabled plan, with no enabled replacement
_pricingClient.ListPlans().Returns([oldPlan]);
var result = await _command.Run(organization);
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.IsType<ConflictException>(unhandled.Exception);
Assert.Equal("Could not find the current, enabled plan for organization's tier and cadence", unhandled.Exception.Message);
}
[Fact]
public async Task Run_Organization_WithNonDisabledPlan_PasswordManagerOnly_Success()
{
var organizationId = Guid.NewGuid();
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually
};
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 10 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
var newSubscription = new Subscription
@@ -89,30 +247,26 @@ public class RestartSubscriptionCommandTests
Id = "sub_new",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
}
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([plan]);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is((SubscriptionCreateOptions options) =>
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.CollectionMethod == CollectionMethod.ChargeAutomatically &&
options.Customer == "cus_123" &&
options.Items.Count == 2 &&
options.Items[0].Price == "price_1" &&
options.Items[0].Quantity == 1 &&
options.Items[1].Price == "price_2" &&
options.Items[1].Quantity == 2 &&
options.Metadata["key"] == "value" &&
options.Items.Count == 1 &&
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.Items[0].Quantity == 10 &&
options.Metadata["organizationId"] == organizationId.ToString() &&
options.OffSession == true &&
options.TrialPeriodDays == 0));
@@ -120,96 +274,417 @@ public class RestartSubscriptionCommandTests
org.Id == organizationId &&
org.GatewaySubscriptionId == "sub_new" &&
org.Enabled == true &&
org.ExpirationDate == currentPeriodEnd));
org.ExpirationDate == currentPeriodEnd &&
org.PlanType == PlanType.EnterpriseAnnually));
}
[Fact]
public async Task Run_Provider_Success_ReturnsNone()
public async Task Run_Organization_WithNonDisabledPlan_WithStorage_Success()
{
var providerId = Guid.NewGuid();
var provider = new Provider { Id = providerId };
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }]
},
Metadata = new Dictionary<string, string>()
};
var newSubscription = new Subscription
{
Id = "sub_new",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
]
}
};
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(provider);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(prov =>
prov.Id == providerId &&
prov.GatewaySubscriptionId == "sub_new" &&
prov.Enabled == true));
}
[Fact]
public async Task Run_User_Success_ReturnsNone()
{
var userId = Guid.NewGuid();
var user = new User { Id = userId };
var organizationId = Guid.NewGuid();
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsAnnually
};
var plan = MockPlans.Get(PlanType.TeamsAnnually);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }]
},
Metadata = new Dictionary<string, string>()
};
var newSubscription = new Subscription
{
Id = "sub_new",
CustomerId = "cus_456",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 5 },
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 3 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
var newSubscription = new Subscription
{
Id = "sub_new_2",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
}
};
_subscriberService.GetSubscription(user).Returns(existingSubscription);
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([plan]);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(user);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Items.Count == 2 &&
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.Items[0].Quantity == 5 &&
options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId &&
options.Items[1].Quantity == 3));
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == userId &&
u.GatewaySubscriptionId == "sub_new" &&
u.Premium == true &&
u.PremiumExpirationDate == currentPeriodEnd));
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
org.Id == organizationId &&
org.GatewaySubscriptionId == "sub_new_2" &&
org.Enabled == true));
}
[Fact]
public async Task Run_Organization_WithSecretsManager_Success()
{
var organizationId = Guid.NewGuid();
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseMonthly
};
var plan = MockPlans.Get(PlanType.EnterpriseMonthly);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_789",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 15 },
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 2 },
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 10 },
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId }, Quantity = 100 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
var newSubscription = new Subscription
{
Id = "sub_new_3",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
}
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([plan]);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Items.Count == 4 &&
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.Items[0].Quantity == 15 &&
options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId &&
options.Items[1].Quantity == 2 &&
options.Items[2].Price == plan.SecretsManager.StripeSeatPlanId &&
options.Items[2].Quantity == 10 &&
options.Items[3].Price == plan.SecretsManager.StripeServiceAccountPlanId &&
options.Items[3].Quantity == 100));
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
org.Id == organizationId &&
org.GatewaySubscriptionId == "sub_new_3" &&
org.Enabled == true));
}
[Fact]
public async Task Run_Organization_WithDisabledPlan_UpgradesToNewPlan_Success()
{
var organizationId = Guid.NewGuid();
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually2023
};
var oldPlan = new DisabledEnterprisePlan2023(true);
var newPlan = MockPlans.Get(PlanType.EnterpriseAnnually);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_old",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 },
new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeStoragePlanId }, Quantity = 5 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
var newSubscription = new Subscription
{
Id = "sub_upgraded",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
}
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([oldPlan, newPlan]);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Items.Count == 2 &&
options.Items[0].Price == newPlan.PasswordManager.StripeSeatPlanId &&
options.Items[0].Quantity == 20 &&
options.Items[1].Price == newPlan.PasswordManager.StripeStoragePlanId &&
options.Items[1].Quantity == 5));
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
org.Id == organizationId &&
org.GatewaySubscriptionId == "sub_upgraded" &&
org.Enabled == true &&
org.PlanType == PlanType.EnterpriseAnnually &&
org.Plan == newPlan.Name &&
org.SelfHost == newPlan.HasSelfHost &&
org.UsePolicies == newPlan.HasPolicies &&
org.UseGroups == newPlan.HasGroups &&
org.UseDirectory == newPlan.HasDirectory &&
org.UseEvents == newPlan.HasEvents &&
org.UseTotp == newPlan.HasTotp &&
org.Use2fa == newPlan.Has2fa &&
org.UseApi == newPlan.HasApi &&
org.UseSso == newPlan.HasSso &&
org.UseOrganizationDomains == newPlan.HasOrganizationDomains &&
org.UseKeyConnector == newPlan.HasKeyConnector &&
org.UseScim == newPlan.HasScim &&
org.UseResetPassword == newPlan.HasResetPassword &&
org.UsersGetPremium == newPlan.UsersGetPremium &&
org.UseCustomPermissions == newPlan.HasCustomPermissions));
}
[Fact]
public async Task Run_Organization_WithStorageAndSecretManagerButNoServiceAccounts_Success()
{
var organizationId = Guid.NewGuid();
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsAnnually
};
var plan = MockPlans.Get(PlanType.TeamsAnnually);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_complex",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 12 },
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 8 },
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 6 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
var newSubscription = new Subscription
{
Id = "sub_complex",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
}
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([plan]);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Items.Count == 3 &&
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.Items[0].Quantity == 12 &&
options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId &&
options.Items[1].Quantity == 8 &&
options.Items[2].Price == plan.SecretsManager.StripeSeatPlanId &&
options.Items[2].Quantity == 6));
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
org.Id == organizationId &&
org.GatewaySubscriptionId == "sub_complex" &&
org.Enabled == true));
}
[Fact]
public async Task Run_Organization_WithSecretsManagerOnly_NoServiceAccounts_Success()
{
var organizationId = Guid.NewGuid();
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsMonthly
};
var plan = MockPlans.Get(PlanType.TeamsMonthly);
var existingSubscription = new Subscription
{
Status = SubscriptionStatus.Canceled,
CustomerId = "cus_sm",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 8 },
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 5 }
]
},
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
};
var newSubscription = new Subscription
{
Id = "sub_sm",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
}
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
_pricingClient.ListPlans().Returns([plan]);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
var result = await _command.Run(organization);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Items.Count == 2 &&
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
options.Items[0].Quantity == 8 &&
options.Items[1].Price == plan.SecretsManager.StripeSeatPlanId &&
options.Items[1].Quantity == 5));
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
org.Id == organizationId &&
org.GatewaySubscriptionId == "sub_sm" &&
org.Enabled == true));
}
private record DisabledEnterprisePlan2023 : Bit.Core.Models.StaticStore.Plan
{
public DisabledEnterprisePlan2023(bool isAnnual)
{
Type = PlanType.EnterpriseAnnually2023;
ProductTier = ProductTierType.Enterprise;
Name = "Enterprise (Annually) 2023";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameEnterprise";
DescriptionLocalizationKey = "planDescEnterprise";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasPolicies = true;
HasSelfHost = true;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
HasSso = true;
HasOrganizationDomains = true;
HasKeyConnector = true;
HasScim = true;
HasResetPassword = true;
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
LegacyYear = 2024;
Disabled = true;
PasswordManager = new PasswordManagerFeatures(isAnnual);
SecretsManager = new SecretsManagerFeatures(isAnnual);
}
private record SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 200;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually-2023";
StripeServiceAccountPlanId = "secrets-manager-service-account-2023-annually";
SeatPrice = 144;
AdditionalPricePerServiceAccount = 12;
}
else
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly-2023";
StripeServiceAccountPlanId = "secrets-manager-service-account-2023-monthly";
SeatPrice = 13;
AdditionalPricePerServiceAccount = 1;
}
}
}
private record PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
AdditionalStoragePricePerGb = 4;
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-enterprise-org-seat-annually-old";
SeatPrice = 72;
}
else
{
StripeSeatPlanId = "2023-enterprise-seat-monthly-old";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 7;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}
}