1
0
mirror of https://github.com/bitwarden/server synced 2026-02-15 07:55:18 +00:00

Add the reversion implementation and unit test

This commit is contained in:
Cy Okeke
2026-01-15 13:13:40 +01:00
parent 2e0e103076
commit bbce95d76a
7 changed files with 1057 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -570,6 +571,8 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan>());
// Act
await _sut.HandleAsync(parsedEvent);
@@ -1132,6 +1135,193 @@ public class SubscriptionUpdatedHandlerTests
.ListInvoices(Arg.Any<InvoiceListOptions>());
}
[Fact]
public async Task HandleAsync_TrialToActive_WithPremiumUpgradeMetadata_CleansUpMetadata()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeSubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Plan = new Plan { Id = "test-plan" } }]
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organizationId.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = Guid.NewGuid().ToString(),
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = DateTime.UtcNow.ToString("O"),
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "5",
[StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually-2020",
["other_metadata"] = "should_remain"
}
};
var previousSubscription = new Subscription
{
Id = "sub_test",
Status = StripeSubscriptionStatus.Trialing
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_test", PlanType = PlanType.EnterpriseAnnually2023 };
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UpgradedOrganizationId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumUserId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && // Organization ID preserved
opts.Metadata.ContainsKey("other_metadata"))); // Other metadata preserved
}
[Fact]
public async Task HandleAsync_TrialToActive_WithoutOrganizationId_SkipsCleanup()
{
// Arrange - Subscription was already reverted (no OrganizationId)
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeSubscriptionStatus.Active,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020",
[StripeConstants.MetadataKeys.UserId] = Guid.NewGuid().ToString()
// No OrganizationId - indicates reversion already happened
}
};
var previousSubscription = new Subscription
{
Id = "sub_test",
Status = StripeSubscriptionStatus.Trialing
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var userId = Guid.NewGuid();
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
var user = new Bit.Core.Entities.User { Id = userId, Premium = false };
_userService.GetUserByIdAsync(userId).Returns(user);
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
Seat = new PremiumPurchasable { StripePriceId = "premium", Price = 10m },
Storage = new PremiumPurchasable { StripePriceId = "storage", Price = 4m }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - cleanup should be skipped because OrganizationId is missing (race condition detected)
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(opts =>
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId)));
}
[Fact]
public async Task HandleAsync_TrialToActive_WithoutPremiumUpgradeMetadata_SkipsCleanup()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeSubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Plan = new Plan { Id = "test-plan" } }]
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString()
// No premium upgrade metadata
}
};
var previousSubscription = new Subscription
{
Id = "sub_test",
Status = StripeSubscriptionStatus.Trialing
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
var organization = new Organization { Id = organizationId, GatewaySubscriptionId = "sub_test", PlanType = PlanType.EnterpriseAnnually2023 };
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - cleanup should not be called because there's no premium upgrade metadata
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Metadata != null));
}
public static IEnumerable<object[]> GetNonActiveSubscriptions()
{
return new List<object[]>

View File

@@ -397,6 +397,9 @@ public class UpgradePremiumToOrganizationCommandTests
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UpgradedOrganizationId) &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousStoragePriceId] == "personal-storage-gb-annually" &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) &&
opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User
}
@@ -600,6 +603,8 @@ public class UpgradePremiumToOrganizationCommandTests
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousStoragePriceId] == "personal-storage-gb-annually" &&
opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat
opts.Items.Count(i => i.Deleted == true) == 2));
}

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
@@ -195,6 +196,580 @@ public class SubscriberServiceTests
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}
[Theory, BitAutoData]
public async Task CancelSubscription_WithPremiumUpgradeMetadata_DuringTrial_RevertsToOriginalPremiumPlan(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var userId = Guid.NewGuid();
var user = new Bit.Core.Entities.User
{
Id = userId,
Premium = false,
GatewaySubscriptionId = null
};
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Trialing,
CustomerId = "cus_test",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_org_seat", Price = new Price { Id = "org-seat-price" } }
}
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(),
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = DateTime.UtcNow.AddMonths(1).ToString("O"),
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "5",
[StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually-2020",
[StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString()
}
};
var premiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan
{
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-annually",
Price = 4m,
Provided = 1
},
Available = true
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var userRepository = sutProvider.GetDependency<Bit.Core.Repositories.IUserRepository>();
var organizationRepository = sutProvider.GetDependency<Bit.Core.Repositories.IOrganizationRepository>();
var pricingClient = sutProvider.GetDependency<Bit.Core.Billing.Pricing.IPricingClient>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
userRepository.GetByIdAsync(userId).Returns(user);
pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
// Act
await sutProvider.Sut.CancelSubscription(
organization,
new OffboardingSurveyResponse { UserId = Guid.NewGuid() },
false);
// Assert
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>((SubscriptionUpdateOptions opts) =>
opts.Items != null &&
opts.Items.Count == 3 && // 1 delete + 1 premium seat + 1 storage
opts.Items.Count(i => i.Deleted == true) == 1 &&
opts.Items.Any(i => i.Price == "premium-annually-2020" && i.Quantity == 1) &&
opts.Items.Any(i => i.Price == "storage-annually-2020" && i.Quantity == 5) &&
opts.TrialEnd.Value == SubscriptionTrialEnd.Now &&
opts.Metadata[StripeConstants.MetadataKeys.UserId] == userId.ToString() &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId)));
await userRepository.Received(1).ReplaceAsync(Arg.Is<Bit.Core.Entities.User>(u =>
u.Id == userId &&
u.Premium == true &&
u.GatewaySubscriptionId == "sub_test"));
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.GatewaySubscriptionId == null));
}
[Theory, BitAutoData]
public async Task CancelSubscription_WithPremiumUpgradeMetadata_AfterTrial_DoesNotRevert(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Active, // Already active, not trialing
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = Guid.NewGuid().ToString(),
[StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString()
}
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
var offboardingSurveyResponse = new OffboardingSurveyResponse
{
UserId = Guid.NewGuid(),
Reason = "other",
Feedback = "test"
};
// Act
await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false);
// Assert - should fall through to standard cancellation, not reversion
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.CancelAtPeriodEnd == true)); // Standard cancellation
}
[Theory, BitAutoData]
public async Task CancelSubscription_WithoutPremiumUpgradeMetadata_UsesStandardCancellation(
Organization organization,
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Active,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString()
// No premium upgrade metadata
}
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
var offboardingSurveyResponse = new OffboardingSurveyResponse
{
UserId = Guid.NewGuid(),
Reason = "other",
Feedback = "test"
};
// Act
await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false);
// Assert - should use standard cancellation
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.CancelAtPeriodEnd == true));
}
[Theory, BitAutoData]
public async Task CancelSubscription_WithPremiumUpgradeMetadata_UserNotFound_ThrowsBillingException(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var userId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Trialing,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString()
}
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var userRepository = sutProvider.GetDependency<Bit.Core.Repositories.IUserRepository>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
userRepository.GetByIdAsync(userId).ReturnsNull();
// Act & Assert
var exception = await Assert.ThrowsAsync<BillingException>(() =>
sutProvider.Sut.CancelSubscription(
organization,
new OffboardingSurveyResponse { UserId = Guid.NewGuid() },
false));
Assert.Equal("Failed to revert subscription upgrade", exception.Message);
Assert.NotNull(exception.InnerException);
Assert.IsType<BillingException>(exception.InnerException);
Assert.Equal("Cannot revert subscription - original Premium user not found", exception.InnerException.Message);
}
[Theory, BitAutoData]
public async Task CancelSubscription_WithPremiumUpgradeMetadata_WrongOrganizationId_DoesNotRevert(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var differentOrgId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Active,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = differentOrgId.ToString(), // Wrong org
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = Guid.NewGuid().ToString()
}
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
var offboardingSurveyResponse = new OffboardingSurveyResponse
{
UserId = Guid.NewGuid(),
Reason = "other",
Feedback = "test"
};
// Act
await sutProvider.Sut.CancelSubscription(organization, offboardingSurveyResponse, false);
// Assert - should fall through to standard cancellation
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.CancelAtPeriodEnd == true));
}
[Theory, BitAutoData]
public async Task CancelSubscription_DuringReversion_UsesHistoricalSeatPriceFromMetadata(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var userId = Guid.NewGuid();
var user = new Bit.Core.Entities.User { Id = userId, Premium = false };
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Trialing,
CustomerId = "cus_test",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_org", Price = new Price { Id = "org-price" } }
}
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually-2020", // Historical
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(),
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "0"
}
};
var currentPremiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan
{
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually", // Current (different from metadata)
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-annually",
Price = 4m,
Provided = 1
},
Available = true
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var userRepository = sutProvider.GetDependency<Bit.Core.Repositories.IUserRepository>();
var pricingClient = sutProvider.GetDependency<Bit.Core.Billing.Pricing.IPricingClient>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
userRepository.GetByIdAsync(userId).Returns(user);
pricingClient.GetAvailablePremiumPlan().Returns(currentPremiumPlan);
// Act
await sutProvider.Sut.CancelSubscription(
organization,
new OffboardingSurveyResponse { UserId = Guid.NewGuid() },
false);
// Assert - should use historical price, not current
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Any(i => i.Price == "premium-annually-2020"))); // Historical, not "premium-annually"
}
[Theory, BitAutoData]
public async Task CancelSubscription_DuringReversion_UsesHistoricalStoragePriceFromMetadata(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var userId = Guid.NewGuid();
var user = new Bit.Core.Entities.User { Id = userId, Premium = false };
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Trialing,
CustomerId = "cus_test",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_org", Price = new Price { Id = "org-price" } }
}
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(),
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "3",
[StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually-2020" // Historical
}
};
var currentPremiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan
{
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-annually", // Current (different from metadata)
Price = 4m,
Provided = 1
},
Available = true
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var userRepository = sutProvider.GetDependency<Bit.Core.Repositories.IUserRepository>();
var pricingClient = sutProvider.GetDependency<Bit.Core.Billing.Pricing.IPricingClient>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
userRepository.GetByIdAsync(userId).Returns(user);
pricingClient.GetAvailablePremiumPlan().Returns(currentPremiumPlan);
// Act
await sutProvider.Sut.CancelSubscription(
organization,
new OffboardingSurveyResponse { UserId = Guid.NewGuid() },
false);
// Assert - should use historical storage price, not current
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Any(i => i.Price == "storage-annually-2020" && i.Quantity == 3))); // Historical, not "storage-annually"
}
[Theory, BitAutoData]
public async Task CancelSubscription_DuringReversion_WithoutStoragePriceId_FallsBackToCurrentPrice(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var userId = Guid.NewGuid();
var user = new Bit.Core.Entities.User { Id = userId, Premium = false };
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Trialing,
CustomerId = "cus_test",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_org", Price = new Price { Id = "org-price" } }
}
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(),
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "2"
// No PreviousStoragePriceId - should fallback to current
}
};
var currentPremiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan
{
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-annually-current", // Current price
Price = 4m,
Provided = 1
},
Available = true
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var userRepository = sutProvider.GetDependency<Bit.Core.Repositories.IUserRepository>();
var pricingClient = sutProvider.GetDependency<Bit.Core.Billing.Pricing.IPricingClient>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
userRepository.GetByIdAsync(userId).Returns(user);
pricingClient.GetAvailablePremiumPlan().Returns(currentPremiumPlan);
// Act
await sutProvider.Sut.CancelSubscription(
organization,
new OffboardingSurveyResponse { UserId = Guid.NewGuid() },
false);
// Assert - should fallback to current storage price
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Any(i => i.Price == "storage-annually-current" && i.Quantity == 2))); // Current price used
}
[Theory, BitAutoData]
public async Task CancelSubscription_DuringReversion_RemovesAllPremiumUpgradeMetadata(
SutProvider<SubscriberService> sutProvider)
{
// Arrange
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test"
};
var userId = Guid.NewGuid();
var user = new Bit.Core.Entities.User { Id = userId, Premium = false };
var subscription = new Subscription
{
Id = "sub_test",
Status = StripeConstants.SubscriptionStatus.Trialing,
CustomerId = "cus_test",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_org", Price = new Price { Id = "org-price" } }
}
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = "premium-annually",
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organization.Id.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = userId.ToString(),
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = DateTime.UtcNow.AddMonths(1).ToString("O"),
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = "5",
[StripeConstants.MetadataKeys.PreviousStoragePriceId] = "storage-annually",
[StripeConstants.MetadataKeys.OrganizationId] = organization.Id.ToString(),
["other_metadata"] = "should_remain"
}
};
var premiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan
{
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-annually",
Price = 4m,
Provided = 1
},
Available = true
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var userRepository = sutProvider.GetDependency<Bit.Core.Repositories.IUserRepository>();
var pricingClient = sutProvider.GetDependency<Bit.Core.Billing.Pricing.IPricingClient>();
stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(subscription);
userRepository.GetByIdAsync(userId).Returns(user);
pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
// Act
await sutProvider.Sut.CancelSubscription(
organization,
new OffboardingSurveyResponse { UserId = Guid.NewGuid() },
false);
// Assert - all premium upgrade metadata should be removed
await stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_test",
Arg.Is<SubscriptionUpdateOptions>(opts =>
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UpgradedOrganizationId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumUserId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousStoragePriceId) &&
!opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) &&
opts.Metadata[StripeConstants.MetadataKeys.UserId] == userId.ToString() &&
opts.Metadata.ContainsKey("other_metadata"))); // Other metadata preserved
}
#endregion
#region GetCustomer