mirror of
https://github.com/bitwarden/server.git
synced 2026-01-11 19:57:01 +00:00
[PM-29598] Create Subscription Upgrade Endpoint (#6787)
Some checks are pending
Collect code references / Check for secret access (push) Waiting to run
Collect code references / Code reference collection (push) Blocked by required conditions
Scan / Check PR run (push) Waiting to run
Scan / Checkmarx (push) Blocked by required conditions
Scan / Sonar (push) Blocked by required conditions
Testing / Run tests (push) Waiting to run
Some checks are pending
Collect code references / Check for secret access (push) Waiting to run
Collect code references / Code reference collection (push) Blocked by required conditions
Scan / Check PR run (push) Waiting to run
Scan / Checkmarx (push) Blocked by required conditions
Scan / Sonar (push) Blocked by required conditions
Testing / Run tests (push) Waiting to run
* Add the ticket implementation * Add the unit test * Fix the lint and test issues * resolve pr comments * Fix the error on the test file * Review suggestion and fixes * resolve the api access comments * Gte the key from the client * Add the gateway type as stripe * Address the legacy plans issues * Resolve the misunderstanding * Add additional storage that we will need if they revert * Add the previous premium UserId
This commit is contained in:
parent
b1cf59b1bf
commit
e705fe3f3f
7 changed files with 933 additions and 2 deletions
|
|
@ -25,7 +25,8 @@ public class AccountBillingVNextController(
|
|||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IGetUserLicenseQuery getUserLicenseQuery,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
|
||||
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
|
||||
{
|
||||
[HttpGet("credit")]
|
||||
[InjectUser]
|
||||
|
|
@ -100,4 +101,15 @@ public class AccountBillingVNextController(
|
|||
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPost("upgrade")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpgradePremiumToOrganizationAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] UpgradePremiumToOrganizationRequest request)
|
||||
{
|
||||
var (organizationName, key, planType) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Premium;
|
||||
|
||||
public class UpgradePremiumToOrganizationRequest
|
||||
{
|
||||
[Required]
|
||||
public string OrganizationName { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
public string Key { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public ProductTierType Tier { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public PlanCadenceType Cadence { get; set; }
|
||||
|
||||
private PlanType PlanType =>
|
||||
Tier switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.TeamsMonthly
|
||||
: PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.EnterpriseMonthly
|
||||
: PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
|
||||
};
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
|
||||
}
|
||||
|
|
@ -67,6 +67,10 @@ public static class StripeConstants
|
|||
public const string BraintreeCustomerId = "btCustomerId";
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string PreviousAdditionalStorage = "previous_additional_storage";
|
||||
public const string PreviousPeriodEndDate = "previous_period_end_date";
|
||||
public const string PreviousPremiumPriceId = "previous_premium_price_id";
|
||||
public const string PreviousPremiumUserId = "previous_premium_user_id";
|
||||
public const string ProviderId = "providerId";
|
||||
public const string Region = "region";
|
||||
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ public static class ServiceCollectionExtensions
|
|||
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
|
||||
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
|
||||
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
|
||||
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
|
||||
}
|
||||
|
||||
private static void AddPremiumQueries(this IServiceCollection services)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands;
|
||||
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.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Premium.Commands;
|
||||
/// <summary>
|
||||
/// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization
|
||||
/// and transferring the subscription from the User to the Organization.
|
||||
/// </summary>
|
||||
public interface IUpgradePremiumToOrganizationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Upgrades a Premium subscription to an Organization subscription.
|
||||
/// </summary>
|
||||
/// <param name="user">The user with an active Premium subscription to upgrade.</param>
|
||||
/// <param name="organizationName">The name for the new organization.</param>
|
||||
/// <param name="key">The encrypted organization key for the owner.</param>
|
||||
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
|
||||
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
||||
Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
string organizationName,
|
||||
string key,
|
||||
PlanType targetPlanType);
|
||||
}
|
||||
|
||||
public class UpgradePremiumToOrganizationCommand(
|
||||
ILogger<UpgradePremiumToOrganizationCommand> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
: BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
string organizationName,
|
||||
string key,
|
||||
PlanType targetPlanType) => HandleAsync<None>(async () =>
|
||||
{
|
||||
// Validate that the user has an active Premium subscription
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
{
|
||||
return new BadRequest("User does not have an active Premium subscription.");
|
||||
}
|
||||
|
||||
// Hardcode seats to 1 for upgrade flow
|
||||
const int seats = 1;
|
||||
|
||||
// Fetch the current Premium subscription from Stripe
|
||||
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
|
||||
|
||||
// Fetch all premium plans to find which specific plan the user is on
|
||||
var premiumPlans = await pricingClient.ListPremiumPlans();
|
||||
|
||||
// Find the password manager subscription item (seat, not storage) and match it to a plan
|
||||
var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription item not found.");
|
||||
}
|
||||
|
||||
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
|
||||
// Get the target organization plan
|
||||
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
|
||||
|
||||
// Build the list of subscription item updates
|
||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
|
||||
|
||||
// Delete the user's specific password manager item
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
|
||||
// Delete the storage item if it exists for this user's plan
|
||||
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
||||
|
||||
// Capture the previous additional storage quantity for potential revert
|
||||
var previousAdditionalStorage = storageItem?.Quantity ?? 0;
|
||||
|
||||
if (storageItem != null)
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = storageItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
}
|
||||
|
||||
// Add new organization subscription items
|
||||
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = targetPlan.PasswordManager.StripePlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = seats
|
||||
});
|
||||
}
|
||||
|
||||
// Generate organization ID early to include in metadata
|
||||
var organizationId = CoreHelpers.GenerateComb();
|
||||
|
||||
// Build the subscription update options
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.None,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
|
||||
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
|
||||
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
|
||||
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
|
||||
}
|
||||
};
|
||||
|
||||
// Create the Organization entity
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = organizationName,
|
||||
BillingEmail = user.Email,
|
||||
PlanType = targetPlan.Type,
|
||||
Seats = (short)seats,
|
||||
MaxCollections = targetPlan.PasswordManager.MaxCollections,
|
||||
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
|
||||
UsePolicies = targetPlan.HasPolicies,
|
||||
UseSso = targetPlan.HasSso,
|
||||
UseGroups = targetPlan.HasGroups,
|
||||
UseEvents = targetPlan.HasEvents,
|
||||
UseDirectory = targetPlan.HasDirectory,
|
||||
UseTotp = targetPlan.HasTotp,
|
||||
Use2fa = targetPlan.Has2fa,
|
||||
UseApi = targetPlan.HasApi,
|
||||
UseResetPassword = targetPlan.HasResetPassword,
|
||||
SelfHost = targetPlan.HasSelfHost,
|
||||
UsersGetPremium = targetPlan.UsersGetPremium,
|
||||
UseCustomPermissions = targetPlan.HasCustomPermissions,
|
||||
UseScim = targetPlan.HasScim,
|
||||
Plan = targetPlan.Name,
|
||||
Gateway = GatewayType.Stripe,
|
||||
Enabled = true,
|
||||
LicenseKey = CoreHelpers.SecureRandomString(20),
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
Status = OrganizationStatusType.Created,
|
||||
UsePasswordManager = true,
|
||||
UseSecretsManager = false,
|
||||
UseOrganizationDomains = targetPlan.HasOrganizationDomains,
|
||||
GatewayCustomerId = user.GatewayCustomerId,
|
||||
GatewaySubscriptionId = currentSubscription.Id
|
||||
};
|
||||
|
||||
// Update the subscription in Stripe
|
||||
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);
|
||||
|
||||
// Save the organization
|
||||
await organizationRepository.CreateAsync(organization);
|
||||
|
||||
// Create organization API key
|
||||
await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30),
|
||||
Type = OrganizationApiKeyType.Default,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
// Update cache
|
||||
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
|
||||
// Create OrganizationUser for the upgrading user as owner
|
||||
var organizationUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Key = key,
|
||||
AccessSecretsManager = false,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
CreationDate = organization.CreationDate,
|
||||
RevisionDate = organization.CreationDate
|
||||
};
|
||||
organizationUser.SetNewId();
|
||||
await organizationUserRepository.CreateAsync(organizationUser);
|
||||
|
||||
// Remove subscription from user
|
||||
user.Premium = false;
|
||||
user.PremiumExpirationDate = null;
|
||||
user.GatewaySubscriptionId = null;
|
||||
user.GatewayCustomerId = null;
|
||||
user.RevisionDate = DateTime.UtcNow;
|
||||
await userService.SaveUserAsync(user);
|
||||
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
|
|
@ -17,12 +17,14 @@ public class AccountBillingVNextControllerTests
|
|||
{
|
||||
private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand;
|
||||
private readonly IGetUserLicenseQuery _getUserLicenseQuery;
|
||||
private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand;
|
||||
private readonly AccountBillingVNextController _sut;
|
||||
|
||||
public AccountBillingVNextControllerTests()
|
||||
{
|
||||
_updatePremiumStorageCommand = Substitute.For<IUpdatePremiumStorageCommand>();
|
||||
_getUserLicenseQuery = Substitute.For<IGetUserLicenseQuery>();
|
||||
_upgradePremiumToOrganizationCommand = Substitute.For<IUpgradePremiumToOrganizationCommand>();
|
||||
|
||||
_sut = new AccountBillingVNextController(
|
||||
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
|
||||
|
|
@ -31,7 +33,8 @@ public class AccountBillingVNextControllerTests
|
|||
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
|
||||
_getUserLicenseQuery,
|
||||
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
|
||||
_updatePremiumStorageCommand);
|
||||
_updatePremiumStorageCommand,
|
||||
_upgradePremiumToOrganizationCommand);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,646 @@
|
|||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Premium.Commands;
|
||||
|
||||
public class UpgradePremiumToOrganizationCommandTests
|
||||
{
|
||||
// Concrete test implementation of the abstract Plan record
|
||||
private record TestPlan : Core.Models.StaticStore.Plan
|
||||
{
|
||||
public TestPlan(
|
||||
PlanType planType,
|
||||
string? stripePlanId = null,
|
||||
string? stripeSeatPlanId = null,
|
||||
string? stripePremiumAccessPlanId = null,
|
||||
string? stripeStoragePlanId = null)
|
||||
{
|
||||
Type = planType;
|
||||
ProductTier = ProductTierType.Teams;
|
||||
Name = "Test Plan";
|
||||
IsAnnual = true;
|
||||
NameLocalizationKey = "";
|
||||
DescriptionLocalizationKey = "";
|
||||
CanBeUsedByBusiness = true;
|
||||
TrialPeriodDays = null;
|
||||
HasSelfHost = false;
|
||||
HasPolicies = false;
|
||||
HasGroups = false;
|
||||
HasDirectory = false;
|
||||
HasEvents = false;
|
||||
HasTotp = false;
|
||||
Has2fa = false;
|
||||
HasApi = false;
|
||||
HasSso = false;
|
||||
HasOrganizationDomains = false;
|
||||
HasKeyConnector = false;
|
||||
HasScim = false;
|
||||
HasResetPassword = false;
|
||||
UsersGetPremium = false;
|
||||
HasCustomPermissions = false;
|
||||
UpgradeSortOrder = 0;
|
||||
DisplaySortOrder = 0;
|
||||
LegacyYear = null;
|
||||
Disabled = false;
|
||||
PasswordManager = new PasswordManagerPlanFeatures
|
||||
{
|
||||
StripePlanId = stripePlanId,
|
||||
StripeSeatPlanId = stripeSeatPlanId,
|
||||
StripePremiumAccessPlanId = stripePremiumAccessPlanId,
|
||||
StripeStoragePlanId = stripeStoragePlanId,
|
||||
BasePrice = 0,
|
||||
SeatPrice = 0,
|
||||
ProviderPortalSeatPrice = 0,
|
||||
AllowSeatAutoscale = true,
|
||||
HasAdditionalSeatsOption = true,
|
||||
BaseSeats = 1,
|
||||
HasPremiumAccessOption = !string.IsNullOrEmpty(stripePremiumAccessPlanId),
|
||||
PremiumAccessOptionPrice = 0,
|
||||
MaxSeats = null,
|
||||
BaseStorageGb = 1,
|
||||
HasAdditionalStorageOption = !string.IsNullOrEmpty(stripeStoragePlanId),
|
||||
AdditionalStoragePricePerGb = 0,
|
||||
MaxCollections = null
|
||||
};
|
||||
SecretsManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Core.Models.StaticStore.Plan CreateTestPlan(
|
||||
PlanType planType,
|
||||
string? stripePlanId = null,
|
||||
string? stripeSeatPlanId = null,
|
||||
string? stripePremiumAccessPlanId = null,
|
||||
string? stripeStoragePlanId = null)
|
||||
{
|
||||
return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId);
|
||||
}
|
||||
|
||||
private static PremiumPlan CreateTestPremiumPlan(
|
||||
string seatPriceId = "premium-annually",
|
||||
string storagePriceId = "personal-storage-gb-annually",
|
||||
bool available = true)
|
||||
{
|
||||
return new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
LegacyYear = null,
|
||||
Available = available,
|
||||
Seat = new PremiumPurchasable
|
||||
{
|
||||
StripePriceId = seatPriceId,
|
||||
Price = 10m,
|
||||
Provided = 1
|
||||
},
|
||||
Storage = new PremiumPurchasable
|
||||
{
|
||||
StripePriceId = storagePriceId,
|
||||
Price = 4m,
|
||||
Provided = 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PremiumPlan> CreateTestPremiumPlansList()
|
||||
{
|
||||
return new List<PremiumPlan>
|
||||
{
|
||||
// Current available plan
|
||||
CreateTestPremiumPlan("premium-annually", "personal-storage-gb-annually", available: true),
|
||||
// Legacy plan from 2020
|
||||
CreateTestPremiumPlan("premium-annually-2020", "personal-storage-gb-annually-2020", available: false)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly IUserService _userService = Substitute.For<IUserService>();
|
||||
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
|
||||
private readonly IApplicationCacheService _applicationCacheService = Substitute.For<IApplicationCacheService>();
|
||||
private readonly ILogger<UpgradePremiumToOrganizationCommand> _logger = Substitute.For<ILogger<UpgradePremiumToOrganizationCommand>>();
|
||||
private readonly UpgradePremiumToOrganizationCommand _command;
|
||||
|
||||
public UpgradePremiumToOrganizationCommandTests()
|
||||
{
|
||||
_command = new UpgradePremiumToOrganizationCommand(
|
||||
_logger,
|
||||
_pricingClient,
|
||||
_stripeAdapter,
|
||||
_userService,
|
||||
_organizationRepository,
|
||||
_organizationUserRepository,
|
||||
_organizationApiKeyRepository,
|
||||
_applicationCacheService);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserNotPremium_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = false;
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserNoGatewaySubscriptionId_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = null;
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserEmptyGatewaySubscriptionId_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "";
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
user.Id = Guid.NewGuid();
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage)
|
||||
opts.Items.Any(i => i.Deleted == true) &&
|
||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
||||
|
||||
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
||||
o.Name == "My Organization" &&
|
||||
o.Gateway == GatewayType.Stripe &&
|
||||
o.GatewaySubscriptionId == "sub_123" &&
|
||||
o.GatewayCustomerId == "cus_123"));
|
||||
await _organizationUserRepository.Received(1).CreateAsync(Arg.Is<OrganizationUser>(ou =>
|
||||
ou.Key == "encrypted-key" &&
|
||||
ou.Status == OrganizationUserStatusType.Confirmed));
|
||||
await _organizationApiKeyRepository.Received(1).CreateAsync(Arg.Any<OrganizationApiKey>());
|
||||
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
u.Premium == false &&
|
||||
u.GatewaySubscriptionId == null &&
|
||||
u.GatewayCustomerId == null));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.FamiliesAnnually,
|
||||
stripePlanId: "families-plan-annually",
|
||||
stripeSeatPlanId: null // Non-seat-based
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 2 && // 1 deleted + 1 plan
|
||||
opts.Items.Any(i => i.Deleted == true) &&
|
||||
opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1)));
|
||||
|
||||
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
||||
o.Name == "My Families Org"));
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
u.Premium == false &&
|
||||
u.GatewaySubscriptionId == null));
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["userId"] = user.Id.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousPremiumPriceId] == "premium-annually" &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium_legacy",
|
||||
Price = new Price { Id = "premium-annually-2020" }, // Legacy price ID
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_storage_legacy",
|
||||
Price = new Price { Id = "personal-storage-gb-annually-2020" }, // Legacy storage price ID
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify that BOTH legacy items (password manager + storage) are deleted by ID
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat
|
||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted
|
||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted
|
||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_other_product",
|
||||
Price = new Price { Id = "some-other-product-id" }, // Non-premium item
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify that ONLY the premium password manager item is deleted (not other products)
|
||||
// Note: We delete the specific premium item by ID, so other products are untouched
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat
|
||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID
|
||||
opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched)
|
||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserHasAdditionalStorage_CapturesStorageInMetadata(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_storage",
|
||||
Price = new Price { Id = "personal-storage-gb-annually" },
|
||||
Quantity = 5, // User has 5GB additional storage
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify that the additional storage quantity (5) is captured in metadata
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
|
||||
opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat
|
||||
opts.Items.Count(i => i.Deleted == true) == 2));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NoPremiumSubscriptionItemFound_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_other",
|
||||
Price = new Price { Id = "some-other-product" }, // Not a premium plan
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("Premium subscription item not found.", badRequest.Response);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue