1
0
mirror of https://github.com/bitwarden/server synced 2026-02-08 12:40:08 +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

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}