Files
server/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs
cyprain-okeke d07a941896 [PM-37092] feat: Add business plan migration handler to SubscriptionUpdatedHandler (#7707)
* feat(billing): add Organization.ChangePlan extension for structural plan shape

Pure helper that writes plan-derived structural columns (PlanType, Plan,
Use* capability flags, UsersGetPremium, MaxCollections) without touching
customer-purchase columns. Preserves the existing UseKeyConnector carve-out.
Behavior-preserving extraction of the field-copy block at lines 287-310
of UpgradeOrganizationPlanCommand.UpgradePlanAsync.

* refactor(billing): use Organization.ChangePlan in UpgradePlanAsync

Replaces the inline structural field-copy block with a call to the new
ChangePlan helper. Customer-purchase columns (Seats, MaxStorageGb,
Enabled, UseSecretsManager) and the PremiumAccessAddon override on
UsersGetPremium stay inline at the call site. Behavior-preserving.

* feat(billing): register Teams 2020 -> current migration paths

Appends Teams2020AnnualToCurrent (byte 3) and Teams2020MonthlyToCurrent
(byte 4) to MigrationPathId and the MigrationPaths registry. Required for
the business migration handler to resolve cohorts mapped to Teams source
plans; without these entries MigrationPaths.FromId returns null and the
handler no-ops on Teams orgs in Cohort A1. Snapshot tests updated.

* chore(billing): inject migration cohort repositories into SubscriptionUpdatedHandler

Adds IOrganizationPlanMigrationCohortRepository and
IOrganizationPlanMigrationCohortAssignmentRepository as constructor
dependencies in preparation for HandleScheduleTriggeredBusinessMigrationAsync.
No behavior change.

* feat(billing): scaffold business plan Phase-2 migration handler

Adds HandleScheduleTriggeredBusinessMigrationAsync as a sibling call in
HandleAsync's organization branch, gated on PM35215_BusinessPlanPriceMigration.
Initial implementation short-circuits when ScheduleId is null. Locks the
no-op behaviour with NoScheduleId + FeatureFlagOff tests. Full handler body
lands in subsequent commits.

* feat(billing): gate business migration handler on registered source price IDs

Builds the source-price allowlist from MigrationPaths.All, using the
seat-vs-non-seat pattern (HasNonSeatBasedPasswordManagerPlan). All four
Track A 2020 plans register automatically. Skips when the previous
subscription items don't include any registered 2020 source price.

* feat(billing): resolve cohort via assignment row for business migration handler

Reads assignment by organization id (DB is source of truth), then resolves
the cohort and migration path. Stripe subscription.Metadata['migration_cohort_id']
remains stamped by PriceIncreaseScheduler for dashboard attribution but is
not consulted by the handler. Skips with a warning when the assignment is
missing, the cohort is missing, or MigrationPathId references an unregistered
path. Idempotent: skips with info-level log when assignment.MigratedDate is
already set, before any further DB reads.

* feat(billing): defensive target-price sanity check for business migration

After resolving the target plan from cohort.MigrationPath.ToPlan, verifies
the current subscription items contain the target's PM price ID (seat-aware).
Skips with a warning on mismatch to protect against operator data errors or
off-path schedule transitions.

* feat(billing): apply plan shape and mark assignment migrated on Phase 2

Completes HandleScheduleTriggeredBusinessMigrationAsync: loads the org,
calls Organization.ChangePlan(targetPlan), persists via ReplaceAsync, and
sets assignment.MigratedDate + RevisionDate before persisting the
assignment. Happy-path coverage for all four Track A pairs (Teams +
Enterprise, monthly + annual). Teams tests assert the UseScim flip - the
load-bearing capability gain for Teams 2020 -> current.

* Add more unit test

* fix: add UTF-8 BOM to .cs files for editorconfig charset compliance

* Add exception handle

* Code refactoring

* Add more unit testing

* Resolve the pr comment
2026-05-27 10:51:44 +00:00

4126 lines
166 KiB
C#

using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
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;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.PlanMigration.Entities;
using Bit.Core.Billing.Organizations.PlanMigration.Enums;
using Bit.Core.Billing.Organizations.PlanMigration.Repositories;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Models.BitStripe;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Test.Billing.Mocks.Plans;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
using static Bit.Core.Billing.Constants.StripeConstants;
using Event = Stripe.Event;
namespace Bit.Billing.Test.Services;
public class SubscriptionUpdatedHandlerTests
{
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationService _organizationService;
private readonly IStripeAdapter _stripeAdapter;
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IPricingClient _pricingClient;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
private readonly IPriceIncreaseScheduler _priceIncreaseScheduler;
private readonly IFeatureService _featureService;
private readonly IOrganizationPlanMigrationCohortRepository _cohortRepository;
private readonly IOrganizationPlanMigrationCohortAssignmentRepository _cohortAssignmentRepository;
private readonly ILogger<SubscriptionUpdatedHandler> _logger;
private readonly SubscriptionUpdatedHandler _sut;
public SubscriptionUpdatedHandlerTests()
{
_stripeEventService = Substitute.For<IStripeEventService>();
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
_organizationService = Substitute.For<IOrganizationService>();
_stripeAdapter = Substitute.For<IStripeAdapter>();
_organizationSponsorshipRenewCommand = Substitute.For<IOrganizationSponsorshipRenewCommand>();
_userService = Substitute.For<IUserService>();
_userRepository = Substitute.For<IUserRepository>();
_providerService = Substitute.For<IProviderService>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_organizationEnableCommand = Substitute.For<IOrganizationEnableCommand>();
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
_pricingClient = Substitute.For<IPricingClient>();
_providerRepository = Substitute.For<IProviderRepository>();
_providerService = Substitute.For<IProviderService>();
_pushNotificationAdapter = Substitute.For<IPushNotificationAdapter>();
_priceIncreaseScheduler = Substitute.For<IPriceIncreaseScheduler>();
_featureService = Substitute.For<IFeatureService>();
_cohortRepository = Substitute.For<IOrganizationPlanMigrationCohortRepository>();
_cohortAssignmentRepository = Substitute.For<IOrganizationPlanMigrationCohortAssignmentRepository>();
_logger = Substitute.For<ILogger<SubscriptionUpdatedHandler>>();
_sut = new SubscriptionUpdatedHandler(
_stripeEventService,
_stripeEventUtilityService,
_organizationService,
_stripeAdapter,
_organizationSponsorshipRenewCommand,
_userService,
_userRepository,
_organizationRepository,
_organizationEnableCommand,
_organizationDisableCommand,
_pricingClient,
_providerRepository,
_providerService,
_pushNotificationAdapter,
_priceIncreaseScheduler,
_featureService,
_cohortRepository,
_cohortAssignmentRepository,
_logger);
}
[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_DisablesOrganizationAndSetsCancellation()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationDisableCommand.Received(1)
.DisableAsync(organizationId, currentPeriodEnd);
await _pushNotificationAdapter.Received(1)
.NotifyEnabledChangedAsync(organization);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
await _organizationRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task
HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSetsCancellation()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_test123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var currentSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { ["providerId"] = providerId.ToString() },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle },
TestClock = null
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = currentSubscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var provider = new Provider { Id = providerId, Enabled = true };
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
// Verify that UpdateSubscription was called with CancelAt
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
}
[Fact]
public async Task HandleAsync_UnpaidProviderSubscription_WithoutValidTransition_DoesNotDisableProvider()
{
// Arrange
var providerId = Guid.NewGuid();
const string subscriptionId = "sub_123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid // No valid transition (already unpaid)
};
var subscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Status = SubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - No disable or cancellation since there was no valid status transition
Assert.True(provider.Enabled);
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_UnpaidProviderSubscription_WithNonMatchingPreviousStatus_DoesNotDisableProvider()
{
// Arrange
var providerId = Guid.NewGuid();
const string subscriptionId = "sub_123";
// Previous status is Canceled, which is not a valid transition source (Trialing/Active/PastDue)
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Canceled
};
var subscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Status = SubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - No disable or cancellation since the previous status (Canceled) is not a valid transition source
Assert.True(provider.Enabled);
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_IncompleteToIncompleteExpiredTransition_DisablesProvider()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
// Previous status was Incomplete - this is the valid transition for IncompleteExpired
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.IncompleteExpired,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate }
};
var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Incomplete to IncompleteExpired should disable the subscriber but
// must NOT call UpdateSubscriptionAsync: the subscription is already terminal
// and Stripe would reject the update, causing a 500-retry disable loop.
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_IncompleteToIncompleteExpiredUserSubscription_DisablesPremium()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.IncompleteExpired,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var user = new User { Id = userId };
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_userRepository.GetByIdAsync(userId).Returns(user);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - disables Premium but must NOT call UpdateSubscriptionAsync
// on the already-terminal subscription (would 500-retry and re-disable).
await _userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd);
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_IncompleteToIncompleteExpiredOrganizationSubscription_DisablesOrganization()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.IncompleteExpired,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - disables organization but must NOT call UpdateSubscriptionAsync
// on the already-terminal subscription (would 500-retry and re-disable).
await _organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd);
await _pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization);
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_WhenProviderNotFound_SkipsHandler()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_providerRepository.GetByIdAsync(providerId)
.Returns((Provider)null);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — guard exits early, no side effects
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_UnpaidUserSubscription_DisablesPremiumAndSetsCancellation()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var user = new User { Id = userId, Premium = false, PremiumExpirationDate = currentPeriodEnd };
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_userRepository.GetByIdAsync(userId).Returns(user);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userService.Received(1)
.DisablePremiumAsync(userId, currentPeriodEnd);
await _userRepository.Received(2).GetByIdAsync(userId);
await _pushNotificationAdapter.Received(1).NotifyPremiumStatusChangedAsync(user);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
await _stripeAdapter.DidNotReceive()
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
await _stripeAdapter.DidNotReceive()
.ListInvoicesAsync(Arg.Any<StripeInvoiceListOptions>());
}
[Fact]
public async Task HandleAsync_IncompleteExpiredUserSubscription_OnlyUpdatesExpiration()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
// Previous status that doesn't trigger enable/disable logic
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.IncompleteExpired,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
_userRepository.GetByIdAsync(userId).Returns(new User { Id = userId });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - IncompleteExpired is no longer handled specially, only expiration is updated
await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd);
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _stripeAdapter.DidNotReceive()
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
await _stripeAdapter.DidNotReceive()
.ListInvoicesAsync(Arg.Any<StripeInvoiceListOptions>());
}
[Fact]
public async Task HandleAsync_ActiveOrganizationSubscription_EnablesOrganizationAndUpdatesExpiration()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType)
.Returns(plan);
_pricingClient.ListPlans()
.Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationEnableCommand.Received(1)
.EnableAsync(organizationId, currentPeriodEnd);
await _organizationService.Received(1)
.UpdateExpirationDateAsync(organizationId, currentPeriodEnd);
await _pushNotificationAdapter.Received(1)
.NotifyEnabledChangedAsync(organization);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAtPeriodEnd == false &&
options.ProrationBehavior == ProrationBehavior.None));
}
[Fact]
public async Task HandleAsync_ActiveUserSubscription_EnablesPremiumAndUpdatesExpiration()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var user = new User { Id = userId, Premium = true, PremiumExpirationDate = currentPeriodEnd };
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_userRepository.GetByIdAsync(userId).Returns(user);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userService.Received(1)
.EnablePremiumAsync(userId, currentPeriodEnd);
await _userService.Received(1)
.UpdatePremiumExpirationAsync(userId, currentPeriodEnd);
await _userRepository.Received(2).GetByIdAsync(userId);
await _pushNotificationAdapter.Received(1).NotifyPremiumStatusChangedAsync(user);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAtPeriodEnd == false &&
options.ProrationBehavior == ProrationBehavior.None));
}
[Fact]
public async Task HandleAsync_SponsoredSubscription_RenewsSponsorship()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
// Use a previous status that won't trigger enable/disable logic
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually });
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan());
_stripeEventUtilityService.IsSponsoredSubscription(subscription)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationSponsorshipRenewCommand.Received(1)
.UpdateExpirationDateAsync(organizationId, currentPeriodEnd);
}
[Fact]
public async Task
HandleAsync_WhenSubscriptionIsActive_AndOrganizationHasSecretsManagerTrial_AndRemovingSecretsManagerTrial_RemovesPasswordManagerCoupon()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Customer = new Customer
{
Balance = 0,
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
},
Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType)
.Returns(plan);
_pricingClient.ListPlans()
.Returns(MockPlans.Plans);
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { plan = new { id = "secrets-manager-enterprise-seat-annually" } } }
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } }
]
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(organization);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeAdapter.Received(1).DeleteCustomerDiscountAsync(subscription.CustomerId);
await _stripeAdapter.Received(1).DeleteSubscriptionDiscountAsync(subscription.Id);
}
[Fact]
public async Task
HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
},
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" }
}
]
},
Customer = new Customer
{
Balance = 0,
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
},
Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
// Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated
var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 };
var plan = new Teams2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType)
.Returns(plan);
_pricingClient.ListPlans()
.Returns(MockPlans.Plans);
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[]
{
new { plan = new { id = "secrets-manager-teams-seat-annually" } },
}
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } },
]
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(organization);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeAdapter.DidNotReceive().DeleteCustomerDiscountAsync(subscription.CustomerId);
await _stripeAdapter.DidNotReceive().DeleteSubscriptionDiscountAsync(subscription.Id);
}
[Theory]
[MemberData(nameof(GetValidTransitionToActiveSubscriptions))]
public async Task
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasIncompleteOrUnpaid_EnableProviderAndUpdateSubscription(
Subscription previousSubscription)
{
// Arrange
var (providerId, newSubscription, provider, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
_stripeAdapter
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(newSubscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository
.Received(2)
.GetByIdAsync(providerId);
await _providerService
.Received(1)
.UpdateAsync(Arg.Is<Provider>(p => p.Id == providerId && p.Enabled == true));
await _stripeAdapter
.Received(1)
.UpdateSubscriptionAsync(newSubscription.Id,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAtPeriodEnd == false &&
options.ProrationBehavior == ProrationBehavior.None));
}
[Fact]
public async Task
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasCanceled_DoesNotEnableProvider()
{
// Arrange
var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled };
var (providerId, newSubscription, provider, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Canceled is not a valid transition source for SubscriptionBecameActive
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _providerService
.DidNotReceive()
.UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter
.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<string>());
}
[Fact]
public async Task
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasAlreadyActive_DoesNotEnableProvider()
{
// Arrange
var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Active };
var (providerId, newSubscription, provider, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Already Active is not a valid transition for SubscriptionBecameActive
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _providerService
.DidNotReceive()
.UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter
.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<string>());
}
[Fact]
public async Task
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasTrialing_DoesNotEnableProvider()
{
// Arrange
var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Trialing };
var (providerId, newSubscription, provider, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Trialing is not a valid transition source for SubscriptionBecameActive
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _providerService
.DidNotReceive()
.UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter
.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<string>());
}
[Fact]
public async Task
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasPastDue_DoesNotEnableProvider()
{
// Arrange
var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.PastDue };
var (providerId, newSubscription, provider, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - PastDue is not a valid transition source for SubscriptionBecameActive
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _providerService
.DidNotReceive()
.UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter
.DidNotReceiveWithAnyArgs()
.UpdateSubscriptionAsync(Arg.Any<string>());
}
[Fact]
public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndProviderDoesNotExist_NoChanges()
{
// Arrange
var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Unpaid };
var (providerId, newSubscription, _, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository
.Received(1)
.GetByIdAsync(providerId);
await _providerService
.DidNotReceive()
.UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter
.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>());
}
[Fact]
public async Task HandleAsync_ActiveProviderSubscriptionEvent_WithNonMatchingPreviousStatus_DoesNotEnableProvider()
{
// Arrange - Using a previous status (Canceled) that doesn't trigger SubscriptionBecameActive
var previousSubscription = new Subscription { Id = "sub_123", Status = SubscriptionStatus.Canceled };
var (providerId, newSubscription, provider, parsedEvent) =
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(previousSubscription);
_stripeEventService
.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(newSubscription);
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Canceled is not a valid transition source, so no enable logic is triggered
await _stripeEventService
.Received(1)
.GetSubscription(parsedEvent, true, Arg.Any<List<string>>());
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _providerService
.DidNotReceive()
.UpdateAsync(Arg.Any<Provider>());
await _stripeAdapter
.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>());
}
private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent)
CreateProviderTestInputsForUpdatedActiveSubscriptionStatus(Subscription? previousSubscription)
{
var providerId = Guid.NewGuid();
var newSubscription = new Subscription
{
Id = previousSubscription?.Id ?? "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Status = SubscriptionStatus.Active,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var provider = new Provider { Id = providerId, Enabled = false };
var parsedEvent = new Event
{
Data = new EventData
{
Object = newSubscription,
PreviousAttributes =
previousSubscription == null ? null : JObject.FromObject(previousSubscription)
}
};
return (providerId, newSubscription, provider, parsedEvent);
}
[Fact]
public async Task HandleAsync_IncompleteUserSubscription_OnlyUpdatesExpiration()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
// Previous status that doesn't trigger enable/disable logic (already was incomplete)
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Incomplete,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
LatestInvoice = new Invoice { Status = "open" },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_userRepository.GetByIdAsync(userId).Returns(new User { Id = userId });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Incomplete status is no longer handled specially, only expiration is updated
await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd);
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_UnpaidSubscription_ReleasesScheduleBeforeCancellation()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var customerId = "cus_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription { Id = subscriptionId, Status = SubscriptionStatus.Active };
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd, Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData { Object = subscription, PreviousAttributes = JObject.FromObject(previousSubscription) }
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>()).Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new Enterprise2023Plan(true));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _priceIncreaseScheduler.Received(1).Release(customerId, subscriptionId);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_ActiveSubscription_RemovesCancellationAndAddsSchedules()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription { Id = subscriptionId, Status = SubscriptionStatus.Unpaid };
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd, Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData { Object = subscription, PreviousAttributes = JObject.FromObject(previousSubscription) }
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>()).Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new Enterprise2023Plan(true));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _priceIncreaseScheduler.Received(1).ScheduleForSubscription(subscription);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId, Arg.Is<SubscriptionUpdateOptions>(o => o.CancelAtPeriodEnd == false));
}
public static IEnumerable<object[]> GetValidTransitionToActiveSubscriptions()
{
// Only Incomplete and Unpaid are valid previous statuses for SubscriptionBecameActive
return new List<object[]>
{
new object[] { new Subscription { Id = "sub_123", Status = SubscriptionStatus.Unpaid } },
new object[] { new Subscription { Id = "sub_123", Status = SubscriptionStatus.Incomplete } }
};
}
[Fact]
public async Task HandleAsync_ScheduleTriggeredFamiliesMigration_FlagOn_UpdatesOrganization()
{
// Arrange — Families 2019 → FamiliesAnnually migration via schedule
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var familiesPlan = new FamiliesPlan();
var families2019Plan = new Families2019Plan();
var families2025Plan = new Families2025Plan();
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(365),
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
}
]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = families2019Plan.PasswordManager.StripePlanId } } }
}
})
}
};
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.FamiliesAnnually2019,
Plan = "Families 2019",
UsersGetPremium = false,
Seats = 5
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.Equal(PlanType.FamiliesAnnually, organization.PlanType);
Assert.Equal("Families", organization.Plan);
Assert.True(organization.UsersGetPremium);
Assert.Equal(6, organization.Seats);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(o =>
o.Id == organizationId &&
o.PlanType == PlanType.FamiliesAnnually &&
o.UsersGetPremium));
}
[Fact]
public async Task HandleAsync_ScheduleTriggeredMigration_FlagOff_DoesNotUpdateOrganization()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(365) }]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = "personal-org-annually" } } }
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(false);
var organization = new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually };
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan());
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_NoSchedule_FlagOn_DoesNotUpdateOrganization()
{
// Arrange — no ScheduleId means this isn't a schedule transition
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = null,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(365) }]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = "personal-org-annually" } } }
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
var organization = new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually };
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan());
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_ScheduleTriggered_PreviousPriceNotOldFamilies_DoesNotUpdateOrganization()
{
// Arrange — schedule-triggered item change, but previous price is not an old Families price
// (e.g., a storage update on a Families org that happens to have a schedule)
var organizationId = Guid.NewGuid();
var familiesPlan = new FamiliesPlan();
var families2019Plan = new Families2019Plan();
var families2025Plan = new Families2025Plan();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(365),
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
}
]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = "personal-storage-gb-annually" } } }
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan);
_organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_ScheduleTriggered_CurrentPriceNotNewFamilies_DoesNotUpdateOrganization()
{
// Arrange — previous had old Families price but current doesn't have new Families price
var organizationId = Guid.NewGuid();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(365),
Price = new Price { Id = "some-other-price-id" }
}
]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = "personal-org-annually" } } }
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId, PlanType = PlanType.FamiliesAnnually });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_ScheduleTriggered_NoItemChanges_DoesNotUpdateOrganization()
{
// Arrange — schedule present but PreviousAttributes has no items (e.g., status-only change)
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(365) }]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new { status = "active" })
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
_organizationRepository.GetByIdAsync(organizationId).Returns(new Organization { Id = organizationId });
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_ScheduleTriggeredMigration_WhenOrganizationNotFound_SkipsHandler()
{
// Arrange
var organizationId = Guid.NewGuid();
var familiesPlan = new FamiliesPlan();
var families2019Plan = new Families2019Plan();
var families2025Plan = new Families2025Plan();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(365),
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
}
]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = families2019Plan.PasswordManager.StripePlanId } } }
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan);
_organizationRepository.GetByIdAsync(organizationId).ReturnsNull();
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — guard exits early — logs warning, does not throw, does not update
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_ScheduleTriggered_MultipleItems_MatchesFamiliesPrice_UpdatesOrganization()
{
// Arrange — subscription has storage add-on alongside the Families price
var organizationId = Guid.NewGuid();
var familiesPlan = new FamiliesPlan();
var families2019Plan = new Families2019Plan();
var families2025Plan = new Families2025Plan();
var subscription = new Subscription
{
Id = "sub_123",
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(365),
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
},
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(365),
Price = new Price { Id = "personal-storage-gb-annually" },
Quantity = 2
}
]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() }
}
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[] { new { price = new { id = families2019Plan.PasswordManager.StripePlanId } } }
}
})
}
};
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.FamiliesAnnually2019,
Plan = "Families 2019",
UsersGetPremium = false,
Seats = 5
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)
.Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.Equal(PlanType.FamiliesAnnually, organization.PlanType);
Assert.True(organization.UsersGetPremium);
Assert.Equal(6, organization.Seats);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(o => o.Id == organizationId));
}
[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_StampsCancellationOriginMetadata()
{
// The metadata stamp is the sole signal SubscriptionDeletedHandler uses to recognize
// that the eventual customer.subscription.deleted came from the platform-managed
// unpaid lifecycle and should void open invoices.
var organizationId = Guid.NewGuid();
const string subscriptionId = "sub_metadata_stamp";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd, Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
Enabled = true,
PlanType = PlanType.EnterpriseAnnually2023
};
var parsedEvent = new Event
{
Id = "evt_metadata_stamp",
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new Enterprise2023Plan(true));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.Metadata != null &&
options.Metadata.ContainsKey(MetadataKeys.CancellationOrigin) &&
options.Metadata[MetadataKeys.CancellationOrigin] == CancellationOrigins.UnpaidSubscription));
}
[Fact]
public async Task HandleAsync_ActiveFromUnpaidSubscription_ClearsCancellationOriginMetadata()
{
// When the customer pays and the subscription recovers, the marker must be removed
// so a future voluntary cancel doesn't trigger an unwanted invoice void.
// Stripe removes a metadata key when its value is set to empty string.
var organizationId = Guid.NewGuid();
const string subscriptionId = "sub_metadata_clear";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd, Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
},
Metadata = new Dictionary<string, string>
{
{ "organizationId", organizationId.ToString() },
{ MetadataKeys.CancellationOrigin, CancellationOrigins.UnpaidSubscription }
},
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(new Enterprise2023Plan(true));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.Metadata != null &&
options.Metadata.ContainsKey(MetadataKeys.CancellationOrigin) &&
options.Metadata[MetadataKeys.CancellationOrigin] == string.Empty));
}
[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithExemptOrganization_DoesNotDisableAndClearsExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationDisableCommand.DidNotReceive()
.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(o => o.Id == organizationId && !o.ExemptFromBillingAutomation));
}
[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithSubscriptionUpdateBillingReason_DoesNotDisableAndPreservesExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionUpdate }
};
var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — subscription_update billing reason does not match SubscriptionWentUnpaid
// (which filters on subscription_create and subscription_cycle only), so no disable,
// no cancellation, and the exempt flag is left unchanged.
await _organizationDisableCommand.DidNotReceive()
.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithAutomaticPendingInvoiceItemInvoiceBillingReason_DoesNotDisableAndPreservesExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.AutomaticPendingInvoiceItemInvoice }
};
var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — automatic_pending_invoice_item_invoice does not match SubscriptionWentUnpaid
// (which filters on subscription_create and subscription_cycle only), so no disable,
// no cancellation, and the exempt flag is left unchanged.
await _organizationDisableCommand.DidNotReceive()
.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithExemptOrganization_WhenSubsequentWorkFails_DoesNotClearExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_organizationService.UpdateExpirationDateAsync(organizationId, Arg.Any<DateTime?>())
.ThrowsAsync(new Exception("Simulated failure in subsequent work"));
// Act
await Assert.ThrowsAsync<Exception>(() => _sut.HandleAsync(parsedEvent));
// Assert — the flag clear must not have been persisted
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenUserNotFound_SkipsHandler()
{
// Arrange
var userId = Guid.NewGuid();
var subscriptionId = "sub_123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }]
},
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_userRepository.GetByIdAsync(userId).ReturnsNull();
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — guard exits early, no side effects
await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _userService.DidNotReceive().UpdatePremiumExpirationAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_WhenOrganizationNotFound_SkipsHandler()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }]
},
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).ReturnsNull();
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — guard exits early, no side effects
await _organizationDisableCommand.DidNotReceive().DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _organizationService.DidNotReceive().UpdateExpirationDateAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_NoScheduleId_DoesNothing()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_no_schedule";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem> { Data = [] }
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
ScheduleId = null,
Items = new StripeList<SubscriptionItem> { Data = [] },
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(new Enterprise2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — handler bails on null ScheduleId; no org or assignment writes
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Bit.Core.Billing.Organizations.PlanMigration.Entities.OrganizationPlanMigrationCohortAssignment>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_PreviousPriceNotIn2020Allowlist_DoesNothing()
{
// Arrange
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var subscriptionId = "sub_unrelated_item";
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "price_unrelated_storage" },
Plan = new Plan { Id = "price_unrelated_storage" }
}
]
}
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_abc",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "2023-enterprise-org-seat-annually" },
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
});
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — handler resolved the path, then bailed at the source-price intersection.
// No target-plan lookup, no org write, no assignment stamp.
await _pricingClient.DidNotReceive().GetPlanOrThrow(PlanType.EnterpriseAnnually);
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_FeatureFlagOff_DoesNothing()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_flag_off";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "2020-enterprise-org-seat-annually" },
Plan = new Plan { Id = "2020-enterprise-org-seat-annually" }
}
]
}
};
var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active,
ScheduleId = "sub_sched_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "2023-enterprise-org-seat-annually" },
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(new Enterprise2020Plan(true));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — dispatch gate (feature flag) is the outermost guard; no cohort lookups happen
await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
await _cohortAssignmentRepository.DidNotReceive().GetByOrganizationIdAsync(Arg.Any<Guid>());
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_NoAssignment_SkipsSilently()
{
// Arrange
var organizationId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_no_assignment",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_target_current" }, Plan = new Plan { Id = "price_target_current" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).ReturnsNull();
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — no cohort lookup, no DB writes, no warning (most orgs won't be in a cohort
// so logging per-event would be noisy)
await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
_logger.DidNotReceive().Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_AssignmentExistsCohortMissing_LogsWarningAndSkips()
{
// Arrange
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_cohort_missing",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_target_current" }, Plan = new Plan { Id = "price_target_current" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
});
_cohortRepository.GetByIdAsync(cohortId).ReturnsNull();
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — cohort missing, no DB writes
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(organizationId.ToString()) && o.ToString().Contains(cohortId.ToString())),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_AssignmentAlreadyMigrated_LogsInfoAndSkips()
{
// Arrange — assignment is already migrated. Idempotency check fires before cohort lookup.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_idempotent",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_target_current" }, Plan = new Plan { Id = "price_target_current" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId,
MigratedDate = DateTime.UtcNow.AddMinutes(-5) // already migrated
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — idempotency: cohort lookup NEVER happens, no writes
await _cohortRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_TargetPriceNotInCurrentItems_LogsWarningAndSkips()
{
// Arrange — sanity check fires when current items don't carry the target plan's price.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var enterpriseAnnual = new EnterprisePlan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_sanity_fail",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = "price_something_else" },
Plan = new Plan { Id = "price_something_else" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterpriseAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
});
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — sanity check fired, no writes
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(organizationId.ToString())),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_EnterpriseAnnual2020ToCurrent_AppliesAndMarksMigrated()
{
// Arrange
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var enterpriseAnnual = new EnterprisePlan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_happy_ea",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseAnnually2020,
Plan = enterprise2020Annual.Name,
UseScim = false,
Seats = 200,
MaxStorageGb = 50,
};
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterpriseAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — plan shape applied
Assert.Equal(PlanType.EnterpriseAnnually, organization.PlanType);
Assert.Equal(enterpriseAnnual.Name, organization.Plan);
Assert.Equal(enterpriseAnnual.HasScim, organization.UseScim);
Assert.True(organization.UsePasswordManager);
Assert.Equal(enterpriseAnnual.UsersGetPremium, organization.UsersGetPremium);
Assert.Equal(enterpriseAnnual.PasswordManager.MaxCollections, organization.MaxCollections);
// Allocation preserved
Assert.Equal((short)200, organization.Seats);
Assert.Equal((short)50, organization.MaxStorageGb);
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == organizationId && o.PlanType == PlanType.EnterpriseAnnually));
Assert.NotNull(assignment.MigratedDate);
Assert.NotEqual(default, assignment.RevisionDate);
await _cohortAssignmentRepository.Received(1).ReplaceAsync(
Arg.Is<OrganizationPlanMigrationCohortAssignment>(a => a.Id == assignmentId && a.MigratedDate.HasValue));
}
[Fact]
public async Task HandleAsync_BusinessMigration_EnterpriseMonthly2020ToCurrent_AppliesAndMarksMigrated()
{
// Arrange
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var enterprise2020Monthly = new Enterprise2020Plan(false);
var enterpriseMonthly = new EnterprisePlan(false);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Monthly.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Monthly.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_happy_em",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterpriseMonthly.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterpriseMonthly.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.EnterpriseMonthly2020,
Plan = enterprise2020Monthly.Name,
UseScim = false,
Seats = 200,
MaxStorageGb = 50,
};
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(new Enterprise2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(enterprise2020Monthly);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly).Returns(enterpriseMonthly);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Monthly",
MigrationPathId = MigrationPathId.Enterprise2020MonthlyToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.Equal(PlanType.EnterpriseMonthly, organization.PlanType);
Assert.Equal(enterpriseMonthly.Name, organization.Plan);
Assert.Equal((short)200, organization.Seats);
Assert.Equal((short)50, organization.MaxStorageGb);
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == organizationId && o.PlanType == PlanType.EnterpriseMonthly));
Assert.NotNull(assignment.MigratedDate);
Assert.NotEqual(default, assignment.RevisionDate);
await _cohortAssignmentRepository.Received(1).ReplaceAsync(
Arg.Is<OrganizationPlanMigrationCohortAssignment>(a => a.Id == assignmentId && a.MigratedDate.HasValue));
}
[Fact]
public async Task HandleAsync_BusinessMigration_TeamsAnnually2020ToCurrent_AppliesAndMarksMigrated()
{
// Arrange — Teams Track A: UseScim flips false -> true. Load-bearing capability gain.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var teams2020Annual = new Teams2020Plan(true);
var teamsAnnual = new TeamsPlan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teams2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teams2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_happy_ta",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsAnnual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teamsAnnual.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsAnnually2020,
Plan = teams2020Annual.Name,
UseScim = false, // Will flip to true
Seats = 50,
MaxStorageGb = 20,
};
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(new Enterprise2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(teams2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(teamsAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Teams2020Annual",
MigrationPathId = MigrationPathId.Teams2020AnnualToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — UseScim flip is the headline capability gain
Assert.Equal(PlanType.TeamsAnnually, organization.PlanType);
Assert.True(teamsAnnual.HasScim);
Assert.True(organization.UseScim);
// Allocation preserved
Assert.Equal((short)50, organization.Seats);
Assert.Equal((short)20, organization.MaxStorageGb);
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == organizationId && o.PlanType == PlanType.TeamsAnnually));
Assert.NotNull(assignment.MigratedDate);
Assert.NotEqual(default, assignment.RevisionDate);
await _cohortAssignmentRepository.Received(1).ReplaceAsync(
Arg.Is<OrganizationPlanMigrationCohortAssignment>(a => a.Id == assignmentId && a.MigratedDate.HasValue));
}
[Fact]
public async Task HandleAsync_BusinessMigration_TeamsMonthly2020ToCurrent_AppliesAndMarksMigrated()
{
// Arrange — Teams Track A: UseScim flips false -> true.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var teams2020Monthly = new Teams2020Plan(false);
var teamsMonthly = new TeamsPlan(false);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teams2020Monthly.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teams2020Monthly.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_happy_tm",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsMonthly.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teamsMonthly.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsMonthly2020,
Plan = teams2020Monthly.Name,
UseScim = false, // Will flip to true
Seats = 25,
MaxStorageGb = 10,
};
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(new Enterprise2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(teams2020Monthly);
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthly);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Teams2020Monthly",
MigrationPathId = MigrationPathId.Teams2020MonthlyToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.Equal(PlanType.TeamsMonthly, organization.PlanType);
Assert.True(teamsMonthly.HasScim);
Assert.True(organization.UseScim);
Assert.Equal((short)25, organization.Seats);
Assert.Equal((short)10, organization.MaxStorageGb);
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == organizationId && o.PlanType == PlanType.TeamsMonthly));
Assert.NotNull(assignment.MigratedDate);
Assert.NotEqual(default, assignment.RevisionDate);
await _cohortAssignmentRepository.Received(1).ReplaceAsync(
Arg.Is<OrganizationPlanMigrationCohortAssignment>(a => a.Id == assignmentId && a.MigratedDate.HasValue));
}
[Fact]
public async Task HandleAsync_BusinessMigration_PreviousAttributesHasNoItemsData_LogsWarningAndSkips()
{
// Arrange — Stripe ships customer.subscription.updated payloads where
// PreviousAttributes exists but carries no `items.data` (e.g., metadata-only
// changes). The business handler must bail before reaching the cohort
// lookup or the pricing-service allowlist construction.
var organizationId = Guid.NewGuid();
// Serialize an empty Subscription (no `items` data) as PreviousAttributes.
// The handler short-circuits at `previousSubscription?.Items?.Data == null`.
var previousSubscription = new Subscription { Id = "sub_metadata_change" };
var subscription = new Subscription
{
Id = "sub_metadata_change",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem> { Data = [] },
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
// Downstream handlers in HandleAsync also consult the pricing client; provide
// the mocks they need so the assertion below only proves the business
// handler skipped its own allowlist + cohort work.
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(new Enterprise2020Plan(true));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — business handler bailed: no cohort lookup, no migration writes, warning logged
await _cohortAssignmentRepository.DidNotReceive().GetByOrganizationIdAsync(Arg.Any<Guid>());
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(organizationId.ToString())),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_CohortMigrationPathIdNull_LogsWarningAndSkips()
{
// Arrange — cohort row exists but has no MigrationPathId (admin paused the
// cohort or it predates the path-assignment workflow). Handler must skip.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_cohort_no_path",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_target_current" }, Plan = new Plan { Id = "price_target_current" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
});
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "PausedCohort",
MigrationPathId = null,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — null MigrationPathId is a skip; no target-plan lookup, no writes
await _pricingClient.DidNotReceive().GetPlanOrThrow(PlanType.EnterpriseAnnually);
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(organizationId.ToString()) && o.ToString().Contains(cohortId.ToString())),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_CohortReferencesUnregisteredMigrationPathId_LogsWarningAndSkips()
{
// Arrange — cohort row cites a MigrationPathId byte value that the in-memory
// registry no longer recognizes (forward-compat case where a path was added
// to the enum but MigrationPaths.All was not updated). Handler must skip
// rather than NRE.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_unregistered_path",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_target_current" }, Plan = new Plan { Id = "price_target_current" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
});
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "ForwardCompatCohort",
// A byte the registry does not know about. Cast around the enum's named
// members to simulate a persisted row from a future deployment.
MigrationPathId = (MigrationPathId)99,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — unregistered path is a safe skip; no NRE, no target lookup, no writes
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(organizationId.ToString()) && o.ToString().Contains(cohortId.ToString())),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_PricingServiceThrowsBillingException_RethrowsForStripeRetry()
{
// Arrange — a BillingException from the pricing client must bubble out of
// the handler so the webhook returns 500 and Stripe retries the event.
// Swallowing it would mark the migration "handled" without applying it.
var organizationId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_pricing_outage",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_target_current" }, Plan = new Plan { Id = "price_target_current" } }]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
// The first allowlist call throws — simulating pricing-service unavailability
// partway through allowlist construction.
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
.Throws(new BillingException(message: "pricing service unavailable"));
// Act + Assert — BillingException must propagate out of HandleAsync
await Assert.ThrowsAsync<BillingException>(() => _sut.HandleAsync(parsedEvent));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_OrganizationLookupReturnsNull_LogsWarningAndSkips()
{
// Arrange — gating passes but the organization row was deleted between the
// dispatcher's earlier subscriber fetch and this handler's lookup. Handler
// must skip without writing.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var enterpriseAnnual = new EnterprisePlan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_org_missing",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
// Dispatcher's initial subscriber lookup returns a stub so HandleAsync routes
// to the Organization branch; the handler's own lookup returns null.
var dispatcherOrg = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
_organizationRepository.GetByIdAsync(organizationId).Returns(dispatcherOrg, (Organization?)null);
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterpriseAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(
new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
CohortId = cohortId
});
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
IsActive = true
});
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — handler reached the org lookup, saw null, and skipped without writing
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
}
[Fact]
public async Task HandleAsync_BusinessMigration_OrganizationReplaceThrows_SwallowsAndLogsError()
{
// Arrange — a non-BillingException raised during the write phase must be
// logged and absorbed so the rest of HandleAsync (UpdateExpirationDate,
// sponsorship renewal, etc.) still runs and the webhook returns 200.
// The current contract intentionally does not retry on these failures.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var enterpriseAnnual = new EnterprisePlan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_replace_throws",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterpriseAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
IsActive = true
});
_organizationRepository.ReplaceAsync(Arg.Any<Organization>())
.Throws(new InvalidOperationException("simulated DB failure"));
// Act — must NOT throw; generic exceptions are absorbed by the catch-all
await _sut.HandleAsync(parsedEvent);
// Assert — assignment is NOT marked migrated when the org write fails;
// the next webhook will re-attempt the migration.
await _cohortAssignmentRepository.DidNotReceive().ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>());
Assert.Null(assignment.MigratedDate);
}
[Fact]
public async Task HandleAsync_BusinessMigration_AssignmentReplaceFailsAfterOrgWrite_RethrowsAsBillingExceptionForStripeRetry()
{
// Arrange — verifies Fix 4: a failure stamping MigratedDate AFTER the org write
// succeeded must surface (as BillingException) so the webhook returns 500 and
// Stripe retries. ChangePlan is idempotent so the retry safely re-applies.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var enterprise2020Annual = new Enterprise2020Plan(true);
var enterpriseAnnual = new EnterprisePlan(true);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterprise2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_partial_write",
ScheduleId = "sub_sched_x",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = enterpriseAnnual.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2020 };
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly2020).Returns(new Enterprise2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(new Teams2020Plan(true));
_pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly2020).Returns(new Teams2020Plan(false));
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterpriseAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Enterprise2020Annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
IsActive = true
});
_cohortAssignmentRepository.ReplaceAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>())
.Throws(new InvalidOperationException("assignment DB failure"));
// Act + Assert — partial-write is surfaced as BillingException so Stripe retries.
await Assert.ThrowsAsync<BillingException>(() => _sut.HandleAsync(parsedEvent));
// Org was written before the failure
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == organizationId && o.PlanType == PlanType.EnterpriseAnnually));
// Assignment MigratedDate was set in-memory but the write failed; retry will redo it.
Assert.NotNull(assignment.MigratedDate);
}
[Fact]
public async Task HandleAsync_BusinessMigration_Integration_TeamsAnnual2020_AppliesShapeAndStampsAssignmentAgainstInMemoryState()
{
// Integration-style test (per PM-37092 AC): drive a synthetic customer.subscription.updated
// event end-to-end through the handler against in-memory-backed repository substitutes, then
// assert on the resulting Organization shape and the assignment row state — not on
// substitute-interaction counts. The org and assignment instances captured here are the
// same references the handler mutates, so post-Act inspection reads the "stored" state.
var organizationId = Guid.NewGuid();
var cohortId = Guid.NewGuid();
var assignmentId = Guid.NewGuid();
var teams2020Annual = new Teams2020Plan(true);
var teamsAnnual = new TeamsPlan(true);
var organization = new Organization
{
Id = organizationId,
PlanType = PlanType.TeamsAnnually2020,
Plan = teams2020Annual.Name,
UseScim = false,
UsePolicies = teams2020Annual.HasPolicies,
UseSso = teams2020Annual.HasSso,
UseGroups = teams2020Annual.HasGroups,
UseDirectory = teams2020Annual.HasDirectory,
Seats = 50,
MaxStorageGb = 20,
UseSecretsManager = true,
SmSeats = 10,
Name = "Acme Inc.",
Enabled = true,
MaxAutoscaleSeats = 100,
MaxAutoscaleSmSeats = 25
};
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = assignmentId,
OrganizationId = organizationId,
CohortId = cohortId,
ScheduledDate = DateTime.UtcNow.AddDays(-30),
MigratedDate = null,
RevisionDate = DateTime.UtcNow.AddDays(-30)
};
var cohort = new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "Teams2020Annual-Integration",
MigrationPathId = MigrationPathId.Teams2020AnnualToCurrent,
IsActive = true
};
// In-memory-backed repository behaviors: substitutes return the captured instances by id,
// and ReplaceAsync just persists by reference (the entity is mutated in place by the handler).
_organizationRepository.GetByIdAsync(organizationId).Returns(_ => organization);
_cohortAssignmentRepository.GetByOrganizationIdAsync(organizationId).Returns(_ => assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(_ => cohort);
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually2020).Returns(teams2020Annual);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(teamsAnnual);
_pricingClient.ListPlans().Returns(MockPlans.Plans);
var previousSubscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teams2020Annual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teams2020Annual.PasswordManager.StripeSeatPlanId }
}
]
}
};
var subscription = new Subscription
{
Id = "sub_integration_ta2020",
ScheduleId = "sub_sched_integration",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = teamsAnnual.PasswordManager.StripeSeatPlanId },
Plan = new Plan { Id = teamsAnnual.PasswordManager.StripeSeatPlanId }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert — resulting Organization shape reflects target plan structurally...
Assert.Equal(PlanType.TeamsAnnually, organization.PlanType);
Assert.Equal(teamsAnnual.Name, organization.Plan);
Assert.True(organization.UseScim);
Assert.Equal(teamsAnnual.HasPolicies, organization.UsePolicies);
Assert.Equal(teamsAnnual.HasSso, organization.UseSso);
Assert.Equal(teamsAnnual.HasGroups, organization.UseGroups);
Assert.Equal(teamsAnnual.HasDirectory, organization.UseDirectory);
Assert.Equal(teamsAnnual.UsersGetPremium, organization.UsersGetPremium);
Assert.Equal(teamsAnnual.PasswordManager.MaxCollections, organization.MaxCollections);
Assert.True(organization.UsePasswordManager);
// ...customer-purchase columns are preserved (allocation-preserve policy)...
Assert.Equal((short)50, organization.Seats);
Assert.Equal((short)20, organization.MaxStorageGb);
Assert.True(organization.UseSecretsManager);
Assert.Equal(10, organization.SmSeats);
Assert.Equal("Acme Inc.", organization.Name);
Assert.True(organization.Enabled);
Assert.Equal(100, organization.MaxAutoscaleSeats);
Assert.Equal(25, organization.MaxAutoscaleSmSeats);
// ...and the cohort assignment row is stamped as migrated.
Assert.NotNull(assignment.MigratedDate);
Assert.NotEqual(default, assignment.RevisionDate);
Assert.True(assignment.MigratedDate > DateTime.UtcNow.AddMinutes(-1));
}
}