mirror of
https://github.com/bitwarden/server
synced 2026-01-28 15:23:38 +00:00
Add the reversion implementation and unit test
This commit is contained in:
@@ -141,6 +141,9 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
{
|
||||
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
|
||||
}
|
||||
|
||||
// Check if trial just ended and cleanup premium upgrade metadata
|
||||
await CleanupPremiumUpgradeMetadataAfterTrialAsync(parsedEvent, subscription);
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Active when providerId.HasValue:
|
||||
@@ -205,9 +208,23 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
|
||||
private async Task<bool> IsPremiumSubscriptionAsync(Subscription subscription)
|
||||
{
|
||||
if (subscription.Items == null || !subscription.Items.Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var premiumPlans = await _pricingClient.ListPremiumPlans();
|
||||
var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet();
|
||||
return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id));
|
||||
if (premiumPlans == null || !premiumPlans.Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var premiumPriceIds = premiumPlans
|
||||
.Where(p => p.Seat != null && p.Storage != null)
|
||||
.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId })
|
||||
.ToHashSet();
|
||||
|
||||
return subscription.Items.Any(i => i.Price != null && premiumPriceIds.Contains(i.Price.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -392,4 +409,80 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up premium upgrade metadata after a trial period ends successfully.
|
||||
/// When a Premium-to-Organization upgrade trial ends and converts to active,
|
||||
/// the upgrade is finalized and the reversion metadata is no longer needed.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent">The Stripe event containing previous subscription state</param>
|
||||
/// <param name="subscription">The current subscription state</param>
|
||||
private async Task CleanupPremiumUpgradeMetadataAfterTrialAsync(Event parsedEvent, Subscription subscription)
|
||||
{
|
||||
// STEP 1: Fastest check first - check if subscription has premium upgrade metadata
|
||||
// This avoids expensive deserialization for subscriptions without this metadata
|
||||
if (subscription.Metadata == null ||
|
||||
!subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// STEP 2: Check if PreviousAttributes exists (cheap check)
|
||||
if (parsedEvent.Data.PreviousAttributes == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// STEP 3: Expensive operation - deserialize PreviousAttributes (only if metadata exists)
|
||||
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
|
||||
|
||||
// STEP 4: Verify status transition from trialing to active
|
||||
if (previousSubscription?.Status != StripeSubscriptionStatus.Trialing ||
|
||||
subscription.Status != StripeSubscriptionStatus.Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// STEP 5: Check if subscription still has OrganizationId (race condition check)
|
||||
// If reversion already happened, OrganizationId would be removed and we should skip cleanup
|
||||
if (!subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping cleanup for subscription {SubscriptionId} - appears to have been reverted already",
|
||||
subscription.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cleaning up premium upgrade metadata for subscription {SubscriptionId} after trial ended",
|
||||
subscription.Id);
|
||||
|
||||
// Remove all premium upgrade metadata keys while preserving other metadata
|
||||
var updatedMetadata = new Dictionary<string, string>(subscription.Metadata);
|
||||
updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousPremiumPriceId);
|
||||
updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousPeriodEndDate);
|
||||
updatedMetadata.Remove(StripeConstants.MetadataKeys.UpgradedOrganizationId);
|
||||
updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousPremiumUserId);
|
||||
updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousAdditionalStorage);
|
||||
updatedMetadata.Remove(StripeConstants.MetadataKeys.PreviousStoragePriceId);
|
||||
|
||||
await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions
|
||||
{
|
||||
Metadata = updatedMetadata
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully cleaned up premium upgrade metadata for subscription {SubscriptionId}",
|
||||
subscription.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to clean up premium upgrade metadata for subscription {SubscriptionId}",
|
||||
subscription.Id);
|
||||
// Don't throw - this is cleanup and shouldn't fail the webhook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +74,15 @@ public static class StripeConstants
|
||||
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 PreviousStoragePriceId = "previous_storage_price_id";
|
||||
public const string ProviderId = "providerId";
|
||||
public const string Region = "region";
|
||||
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
|
||||
public const string UserId = "userId";
|
||||
public const string StorageReconciled2025 = "storage_reconciled_2025";
|
||||
|
||||
// Premium-to-Organization upgrade reversion metadata
|
||||
public const string UpgradedOrganizationId = "upgraded_organization_id";
|
||||
}
|
||||
|
||||
public static class PaymentBehavior
|
||||
|
||||
@@ -138,8 +138,11 @@ public class UpgradePremiumToOrganizationCommand(
|
||||
{
|
||||
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
|
||||
// Premium-to-Organization upgrade reversion metadata
|
||||
[StripeConstants.MetadataKeys.UpgradedOrganizationId] = organizationId.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
|
||||
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
|
||||
[StripeConstants.MetadataKeys.PreviousStoragePriceId] = usersPremiumPlan.Storage.StripePriceId,
|
||||
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
|
||||
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Entities;
|
||||
@@ -34,6 +35,7 @@ public class SubscriberService(
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<SubscriberService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPricingClient pricingClient,
|
||||
IProviderRepository providerRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
@@ -57,6 +59,16 @@ public class SubscriberService(
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
// Check if this is a Premium-to-Organization upgrade that can be reverted
|
||||
if (subscriber is Organization organization)
|
||||
{
|
||||
var canRevert = await TryRevertPremiumUpgradeAsync(subscription, organization);
|
||||
if (canRevert)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "cancellingUserId", offboardingSurveyResponse.UserId.ToString() }
|
||||
@@ -962,5 +974,178 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to revert a Premium-to-Organization upgrade by checking for premium upgrade metadata
|
||||
/// and reverting the subscription to the original Premium plan if found.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The Stripe subscription to check</param>
|
||||
/// <param name="organization">The organization attempting to cancel</param>
|
||||
/// <returns>True if the reversion was successful, false if no reversion metadata was found</returns>
|
||||
private async Task<bool> TryRevertPremiumUpgradeAsync(Subscription subscription, Organization organization)
|
||||
{
|
||||
// Extract all metadata once
|
||||
var metadata = subscription.Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
// Check if subscription has the premium upgrade metadata
|
||||
if (!metadata.TryGetValue(MetadataKeys.PreviousPremiumPriceId, out var previousPremiumPriceId) ||
|
||||
!metadata.TryGetValue(MetadataKeys.UpgradedOrganizationId, out var upgradedOrganizationIdStr) ||
|
||||
!metadata.TryGetValue(MetadataKeys.PreviousPremiumUserId, out var previousPremiumUserIdStr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse IDs once
|
||||
if (!Guid.TryParse(upgradedOrganizationIdStr, out var upgradedOrganizationId) ||
|
||||
!Guid.TryParse(previousPremiumUserIdStr, out var previousPremiumUserId))
|
||||
{
|
||||
logger.LogWarning("Invalid GUID format in premium upgrade metadata for subscription {SubscriptionId}", subscription.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify that this is the organization that was upgraded
|
||||
if (upgradedOrganizationId != organization.Id)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Organization {OrganizationId} attempted to revert subscription {SubscriptionId} that belongs to organization {ActualOrganizationId}",
|
||||
organization.Id, subscription.Id, upgradedOrganizationId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify subscription is in trial - reversion only allowed during trial period
|
||||
if (subscription.Status != SubscriptionStatus.Trialing)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Cannot revert subscription {SubscriptionId} - not in trial period (current status: {Status})",
|
||||
subscription.Id, subscription.Status);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Reverting Premium-to-Organization upgrade for organization {OrganizationId}", organization.Id);
|
||||
|
||||
// STEP 1: Validate user exists BEFORE making any Stripe changes
|
||||
var user = await userRepository.GetByIdAsync(previousPremiumUserId);
|
||||
if (user == null)
|
||||
{
|
||||
logger.LogError("Cannot revert subscription - user {UserId} not found", previousPremiumUserId);
|
||||
throw new BillingException(message: "Cannot revert subscription - original Premium user not found");
|
||||
}
|
||||
|
||||
// STEP 2: Get the current Premium plan details from pricing client
|
||||
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
|
||||
if (premiumPlan == null || !premiumPlan.Available)
|
||||
{
|
||||
logger.LogError("Cannot revert subscription - Premium plan is not available");
|
||||
throw new BillingException("Premium plan is not currently available");
|
||||
}
|
||||
|
||||
// STEP 3: Parse additional metadata
|
||||
DateTime? periodEnd = null;
|
||||
if (metadata.TryGetValue(MetadataKeys.PreviousPeriodEndDate, out var periodEndStr) &&
|
||||
DateTime.TryParse(periodEndStr, out var parsedPeriodEnd))
|
||||
{
|
||||
periodEnd = parsedPeriodEnd;
|
||||
}
|
||||
|
||||
int additionalStorageGb = 0;
|
||||
if (metadata.TryGetValue(MetadataKeys.PreviousAdditionalStorage, out var storageStr) &&
|
||||
int.TryParse(storageStr, out var storage))
|
||||
{
|
||||
additionalStorageGb = storage;
|
||||
}
|
||||
|
||||
// Get previous storage price ID (if available) or fallback to current
|
||||
string storagePriceId = premiumPlan.Storage.StripePriceId; // Default to current
|
||||
if (metadata.TryGetValue(MetadataKeys.PreviousStoragePriceId, out var previousStoragePriceId))
|
||||
{
|
||||
storagePriceId = previousStoragePriceId; // Use historical price if available
|
||||
}
|
||||
|
||||
// STEP 4: Build subscription items - delete all existing organization items and add Premium items
|
||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
|
||||
|
||||
// Delete all existing organization subscription items
|
||||
foreach (var existingItem in subscription.Items.Data)
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = existingItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
}
|
||||
|
||||
// Add Premium seat item - use the original price from metadata
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = previousPremiumPriceId,
|
||||
Quantity = 1
|
||||
});
|
||||
|
||||
// Add storage item if user had additional storage
|
||||
if (additionalStorageGb > 0)
|
||||
{
|
||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = storagePriceId,
|
||||
Quantity = additionalStorageGb
|
||||
});
|
||||
|
||||
logger.LogInformation("Restoring {StorageGb}GB additional storage for user {UserId}", additionalStorageGb, previousPremiumUserId);
|
||||
}
|
||||
|
||||
// STEP 5: Preserve important metadata and remove premium upgrade metadata
|
||||
var updatedMetadata = new Dictionary<string, string>(metadata);
|
||||
updatedMetadata.Remove(MetadataKeys.PreviousPremiumPriceId);
|
||||
updatedMetadata.Remove(MetadataKeys.PreviousPeriodEndDate);
|
||||
updatedMetadata.Remove(MetadataKeys.UpgradedOrganizationId);
|
||||
updatedMetadata.Remove(MetadataKeys.PreviousPremiumUserId);
|
||||
updatedMetadata.Remove(MetadataKeys.PreviousAdditionalStorage);
|
||||
updatedMetadata.Remove(MetadataKeys.PreviousStoragePriceId);
|
||||
|
||||
// Ensure user ID is in metadata
|
||||
updatedMetadata[MetadataKeys.UserId] = previousPremiumUserId.ToString();
|
||||
|
||||
// Remove organization ID from metadata since this is now a user subscription
|
||||
updatedMetadata.Remove(MetadataKeys.OrganizationId);
|
||||
|
||||
// STEP 6: Update Stripe subscription
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
TrialEnd = SubscriptionTrialEnd.Now, // End the trial immediately
|
||||
Metadata = updatedMetadata
|
||||
};
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions);
|
||||
|
||||
// STEP 7: Update user's Premium status and storage
|
||||
user.Premium = true;
|
||||
user.PremiumExpirationDate = periodEnd;
|
||||
user.GatewaySubscriptionId = subscription.Id;
|
||||
user.GatewayCustomerId = subscription.CustomerId; // Ensure user's customer ID matches subscription
|
||||
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb);
|
||||
|
||||
await userRepository.ReplaceAsync(user);
|
||||
|
||||
// STEP 8: Clear organization's subscription reference
|
||||
organization.GatewaySubscriptionId = null;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully reverted Premium-to-Organization upgrade for organization {OrganizationId}. Subscription {SubscriptionId} restored to user {UserId}",
|
||||
organization.Id, subscription.Id, previousPremiumUserId);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to revert Premium-to-Organization upgrade for organization {OrganizationId}", organization.Id);
|
||||
throw new BillingException(
|
||||
message: "Failed to revert subscription upgrade",
|
||||
innerException: ex);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user