1
0
mirror of https://github.com/bitwarden/server synced 2025-12-31 07:33:43 +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);
}
}