diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f8593f67c654..b0696fdf34ad 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Utilities; using Bit.Core.Entities; using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; @@ -33,7 +34,7 @@ public class UpcomingInvoiceHandler( IOrganizationRepository organizationRepository, IPricingClient pricingClient, IProviderRepository providerRepository, - IStripeFacade stripeFacade, + IStripeAdapter stripeAdapter, IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, @@ -47,7 +48,7 @@ public async Task HandleAsync(Event parsedEvent) var invoice = await stripeEventService.GetInvoice(parsedEvent); var customer = - await stripeFacade.GetCustomer(invoice.CustomerId, + await stripeAdapter.GetCustomerAsync(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); var subscription = customer.Subscriptions.FirstOrDefault(); @@ -145,7 +146,7 @@ private async Task HandleOrganizationUpcomingInvoiceAsync( * If the sponsorship is invalid, then the subscription was updated to use the regular families plan * price. Given that this is the case, we need the new invoice amount */ - invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); + invoice = await stripeAdapter.GetInvoiceAsync(subscription.LatestInvoiceId); } } @@ -169,7 +170,7 @@ private async Task AlignOrganizationTaxConcernsAsync( when determinedTaxExemptStatus != customerTaxExemptStatus: try { - await stripeFacade.UpdateCustomer(subscription.CustomerId, + await stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus }); } catch (Exception exception) @@ -188,7 +189,7 @@ await stripeFacade.UpdateCustomer(subscription.CustomerId, { try { - await stripeFacade.UpdateSubscription(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -239,59 +240,91 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); - organization.PlanType = familiesPlan.Type; - organization.Plan = familiesPlan.Name; - organization.UsersGetPremium = familiesPlan.UsersGetPremium; - organization.Seats = familiesPlan.PasswordManager.BaseSeats; - - var options = new SubscriptionUpdateOptions + try { - Items = - [ - new SubscriptionItemOptions + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + { + var phase2Items = new List { - Id = passwordManagerItem.Id, - Price = familiesPlan.PasswordManager.StripePlanId - } - ], - ProrationBehavior = ProrationBehavior.None - }; + new() { Price = familiesPlan.PasswordManager.StripePlanId, Quantity = 1 } + }; - if (plan.Type == PlanType.FamiliesAnnually2019) - { - options.Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount } - ]; + var storageItem = subscription.Items.FirstOrDefault(i => + i.Price.Id == plan.PasswordManager.StripeStoragePlanId); - var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item => - item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId); + if (storageItem is { Quantity: > 0 }) + { + phase2Items.Add(new SubscriptionSchedulePhaseItemOptions { Price = familiesPlan.PasswordManager.StripeStoragePlanId, Quantity = storageItem.Quantity }); + } - if (premiumAccessAddOnItem != null) - { - options.Items.Add(new SubscriptionItemOptions + var phase2Discounts = plan.Type == PlanType.FamiliesAnnually2019 + ? new List + { + new() { Coupon = CouponIDs.Milestone3SubscriptionDiscount } + } + : null; + + var alreadyScheduled = await SchedulePriceMigrationAsync(subscription, phase2Items, phase2Discounts); + if (alreadyScheduled) { - Id = premiumAccessAddOnItem.Id, - Deleted = true - }); + return true; + } } + else + { + organization.PlanType = familiesPlan.Type; + organization.Plan = familiesPlan.Name; + organization.UsersGetPremium = familiesPlan.UsersGetPremium; + organization.Seats = familiesPlan.PasswordManager.BaseSeats; - var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually"); + var options = new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Price = familiesPlan.PasswordManager.StripePlanId + } + ], + ProrationBehavior = ProrationBehavior.None + }; - if (seatAddOnItem != null) - { - options.Items.Add(new SubscriptionItemOptions + if (plan.Type == PlanType.FamiliesAnnually2019) { - Id = seatAddOnItem.Id, - Deleted = true - }); + options.Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount } + ]; + + var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item => + item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId); + + if (premiumAccessAddOnItem != null) + { + options.Items.Add(new SubscriptionItemOptions + { + Id = premiumAccessAddOnItem.Id, + Deleted = true + }); + } + + var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually"); + + if (seatAddOnItem != null) + { + options.Items.Add(new SubscriptionItemOptions + { + Id = seatAddOnItem.Id, + Deleted = true + }); + } + } + + await organizationRepository.ReplaceAsync(organization); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); } - } - try - { - await organizationRepository.ReplaceAsync(organization); - await stripeFacade.UpdateSubscription(subscription.Id, options); await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan); return true; } @@ -360,7 +393,7 @@ private async Task AlignPremiumUsersTaxConcernsAsync( { try { - await stripeFacade.UpdateSubscription(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -382,7 +415,18 @@ private async Task AlignPremiumUsersSubscriptionConcernsAsync( Event @event, Subscription subscription) { - var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + var premiumPlans = await pricingClient.ListPremiumPlans(); + var oldPlan = premiumPlans.FirstOrDefault(p => !p.Available); + var newPlan = premiumPlans.FirstOrDefault(p => p.Available); + + if (oldPlan == null || newPlan == null) + { + logger.LogWarning("Could not resolve old and new premium plans while processing '{EventType}' event ({EventID})", + @event.Type, @event.Id); + return false; + } + + var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == oldPlan.Seat.StripePriceId); if (premiumItem == null) { @@ -393,21 +437,50 @@ private async Task AlignPremiumUsersSubscriptionConcernsAsync( try { - var plan = await pricingClient.GetAvailablePremiumPlan(); - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions + if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) + { + var phase2Items = new List { - Items = - [ - new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId } - ], - Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ], - ProrationBehavior = ProrationBehavior.None - }); - await SendPremiumRenewalEmailAsync(user, plan); + new() { Price = newPlan.Seat.StripePriceId, Quantity = 1 } + }; + + var storageItem = subscription.Items.FirstOrDefault(i => + i.Price.Id == oldPlan.Storage.StripePriceId); + + if (storageItem is { Quantity: > 0 }) + { + phase2Items.Add(new SubscriptionSchedulePhaseItemOptions { Price = newPlan.Storage.StripePriceId, Quantity = storageItem.Quantity }); + } + + var phase2Discounts = new List + { + new() { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + }; + + var alreadyScheduled = await SchedulePriceMigrationAsync(subscription, phase2Items, phase2Discounts); + if (alreadyScheduled) + { + return true; + } + } + else + { + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = premiumItem.Id, Price = newPlan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ], + ProrationBehavior = ProrationBehavior.None + }); + } + + await SendPremiumRenewalEmailAsync(user, newPlan); return true; } catch (Exception exception) @@ -462,7 +535,7 @@ private async Task AlignProviderTaxConcernsAsync( when determinedTaxExemptStatus != customerTaxExemptStatus: try { - await stripeFacade.UpdateCustomer(subscription.CustomerId, + await stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { TaxExempt = determinedTaxExemptStatus }); } catch (Exception exception) @@ -480,7 +553,7 @@ await stripeFacade.UpdateCustomer(subscription.CustomerId, { try { - await stripeFacade.UpdateSubscription(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -555,6 +628,97 @@ await mailService.SendInvoiceUpcoming( } } + /// + /// Creates a subscription schedule that echoes the current phase and appends a new phase + /// with the specified items and discounts. Returns true if an active schedule already exists + /// for the subscription (indicating the caller should skip further processing), or false + /// after successfully creating a new schedule (indicating the caller should continue with + /// email notifications). + /// + private async Task SchedulePriceMigrationAsync( + Subscription subscription, + List phase2Items, + List? phase2Discounts) + { + var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync( + new SubscriptionScheduleListOptions { Customer = subscription.CustomerId }); + + if (schedules.Data.Any(s => s.SubscriptionId == subscription.Id && s.Status == SubscriptionScheduleStatus.Active)) + { + logger.LogInformation( + "Active subscription schedule already exists for subscription ({SubscriptionId}), skipping schedule creation", + subscription.Id); + return true; + } + + var schedule = await stripeAdapter.CreateSubscriptionScheduleAsync( + new SubscriptionScheduleCreateOptions + { + FromSubscription = subscription.Id + }); + + try + { + var phase1 = schedule.Phases[0]; + + await stripeAdapter.UpdateSubscriptionScheduleAsync(schedule.Id, + new SubscriptionScheduleUpdateOptions + { + EndBehavior = SubscriptionScheduleEndBehavior.Release, + Phases = + [ + new SubscriptionSchedulePhaseOptions + { + StartDate = phase1.StartDate, + EndDate = phase1.EndDate, + Items = phase1.Items.Select(i => new SubscriptionSchedulePhaseItemOptions + { + Price = i.PriceId, + Quantity = i.Quantity + }).ToList(), + Discounts = phase1.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions + { + Coupon = d.CouponId + }).ToList(), + ProrationBehavior = ProrationBehavior.None + }, + new SubscriptionSchedulePhaseOptions + { + StartDate = phase1.EndDate, + Items = phase2Items, + Discounts = phase2Discounts, + ProrationBehavior = ProrationBehavior.None + } + ] + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update subscription schedule ({ScheduleId}) for subscription ({SubscriptionId}), attempting to release orphaned schedule", + schedule.Id, + subscription.Id); + + try + { + await stripeAdapter.ReleaseSubscriptionScheduleAsync(schedule.Id); + } + catch (Exception releaseException) + { + logger.LogError( + releaseException, + "Failed to release orphaned subscription schedule ({ScheduleId}) for subscription ({SubscriptionId})", + schedule.Id, + subscription.Id); + } + + throw; + } + + return false; + } + private async Task SendFamiliesRenewalEmailAsync( Organization organization, Plan familiesPlan, @@ -584,7 +748,7 @@ private async Task SendFamilies2020RenewalEmailAsync(Organization organization, private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan) { - var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + var coupon = await stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); if (coupon == null) { throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found"); @@ -616,7 +780,7 @@ private async Task SendPremiumRenewalEmailAsync( User user, PremiumPlan premiumPlan) { - var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount); + var coupon = await stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount); if (coupon == null) { throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"); diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index a3e314198fb9..73c0379ee1be 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -117,6 +117,23 @@ public static class ProrationBehavior public const string None = "none"; } + public static class SubscriptionScheduleEndBehavior + { + public const string Cancel = "cancel"; + public const string None = "none"; + public const string Release = "release"; + public const string Renew = "renew"; + } + + public static class SubscriptionScheduleStatus + { + public const string Active = "active"; + public const string Canceled = "canceled"; + public const string Completed = "completed"; + public const string NotStarted = "not_started"; + public const string Released = "released"; + } + public static class SubscriptionStatus { public const string Trialing = "trialing"; diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 40f05e07c0fc..747282de9f12 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing.Premium; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; @@ -39,7 +40,7 @@ public class UpcomingInvoiceHandlerTests private readonly IOrganizationRepository _organizationRepository; private readonly IPricingClient _pricingClient; private readonly IProviderRepository _providerRepository; - private readonly IStripeFacade _stripeFacade; + private readonly IStripeAdapter _stripeAdapter; private readonly IStripeEventService _stripeEventService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IUserRepository _userRepository; @@ -62,7 +63,7 @@ public UpcomingInvoiceHandlerTests() _organizationRepository = Substitute.For(); _pricingClient = Substitute.For(); _providerRepository = Substitute.For(); - _stripeFacade = Substitute.For(); + _stripeAdapter = Substitute.For(); _stripeEventService = Substitute.For(); _stripeEventUtilityService = Substitute.For(); _userRepository = Substitute.For(); @@ -77,7 +78,7 @@ public UpcomingInvoiceHandlerTests() _organizationRepository, _pricingClient, _providerRepository, - _stripeFacade, + _stripeAdapter, _stripeEventService, _stripeEventUtilityService, _userRepository, @@ -95,16 +96,16 @@ public async Task HandleAsync_WhenNullSubscription_DoesNothing() var customer = new Customer { Id = "cus_123", Subscriptions = new StripeList { Data = [] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); // Act await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.DidNotReceive() - .UpdateCustomer(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive() + .UpdateCustomerAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -144,6 +145,14 @@ public async Task HandleAsync_WhenValidUser_SendsEmail() Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -152,8 +161,8 @@ public async Task HandleAsync_WhenValidUser_SendsEmail() }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(customerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(customerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -161,7 +170,7 @@ public async Task HandleAsync_WhenValidUser_SendsEmail() .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); // If milestone 2 is disabled, the default email is sent _featureService @@ -227,6 +236,14 @@ public async Task Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -234,8 +251,8 @@ public async Task }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(customerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(customerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -243,8 +260,8 @@ public async Task .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); - _stripeFacade.UpdateSubscription( + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); + _stripeAdapter.UpdateSubscriptionAsync( subscription.Id, Arg.Any()) .Returns(subscription); @@ -256,16 +273,16 @@ public async Task var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }; - _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); // Act await _sut.HandleAsync(parsedEvent); // Assert await _userRepository.Received(1).GetByIdAsync(_userId); - await _pricingClient.Received(1).GetAvailablePremiumPlan(); - await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount); - await _stripeFacade.Received(1).UpdateSubscription( + await _pricingClient.Received(1).ListPremiumPlans(); + await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount); + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is("sub_123"), Arg.Is(o => o.Items[0].Id == priceSubscriptionId && @@ -325,8 +342,8 @@ public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() var plan = new FamiliesPlan(); _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -415,8 +432,8 @@ public async Task var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -440,8 +457,8 @@ public async Task _validateSponsorshipCommand .ValidateSponsorshipAsync(_organizationId) .Returns(false); - _stripeFacade - .GetInvoice(subscription.LatestInvoiceId) + _stripeAdapter + .GetInvoiceAsync(subscription.LatestInvoiceId) .Returns(invoice); _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); @@ -453,7 +470,7 @@ public async Task await _organizationRepository.Received(1).GetByIdAsync(_organizationId); _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); - await _stripeFacade.Received(1).GetInvoice(Arg.Is("inv_latest")); + await _stripeAdapter.Received(1).GetInvoiceAsync(Arg.Is("inv_latest")); await _mailService.Received(1).SendInvoiceUpcoming( Arg.Is>(emails => emails.Contains("org@example.com")), @@ -509,8 +526,8 @@ public async Task HandleAsync_WhenValidOrganization_SendsEmail() var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -529,8 +546,8 @@ public async Task HandleAsync_WhenValidOrganization_SendsEmail() .IsSponsoredSubscription(subscription) .Returns(false); - _stripeFacade - .GetInvoice(subscription.LatestInvoiceId) + _stripeAdapter + .GetInvoiceAsync(subscription.LatestInvoiceId) .Returns(invoice); _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); @@ -583,7 +600,7 @@ public async Task HandleAsync_WhenNonDirectTaxCountryOrganization_SetsReverseCha }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -595,7 +612,7 @@ public async Task HandleAsync_WhenNonDirectTaxCountryOrganization_SetsReverseCha await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( Arg.Is("cus_123"), Arg.Is(o => o.TaxExempt == TaxExempt.Reverse)); } @@ -630,7 +647,7 @@ public async Task HandleAsync_WhenUSOrganizationWithManualReverseCharge_Corrects }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -642,7 +659,7 @@ public async Task HandleAsync_WhenUSOrganizationWithManualReverseCharge_Corrects await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( Arg.Is("cus_123"), Arg.Is(o => o.TaxExempt == TaxExempt.None)); } @@ -677,7 +694,7 @@ public async Task HandleAsync_WhenSwissOrganizationWithReverse_CorrectsTaxExempt }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -689,7 +706,7 @@ public async Task HandleAsync_WhenSwissOrganizationWithReverse_CorrectsTaxExempt await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( "cus_123", Arg.Is(options => options.TaxExempt == TaxExempt.None)); } @@ -724,7 +741,7 @@ public async Task HandleAsync_WhenOrganizationCustomerIsExempt_DoesNotUpdateTaxE }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -736,7 +753,7 @@ public async Task HandleAsync_WhenOrganizationCustomerIsExempt_DoesNotUpdateTaxE await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.DidNotReceive().UpdateCustomer( + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), Arg.Any()); } @@ -778,7 +795,7 @@ public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) @@ -794,12 +811,12 @@ public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() await _providerRepository.Received(2).GetByIdAsync(_providerId); // Verify tax exempt was set to reverse for non-direct-tax-country providers - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( Arg.Is("cus_123"), Arg.Is(o => o.TaxExempt == TaxExempt.Reverse)); // Verify automatic tax was enabled - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is("sub_123"), Arg.Is(o => o.AutomaticTax.Enabled == true)); @@ -851,7 +868,7 @@ public async Task HandleAsync_WhenSwissProviderWithReverse_CorrectsTaxExemptToNo var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) @@ -866,7 +883,7 @@ public async Task HandleAsync_WhenSwissProviderWithReverse_CorrectsTaxExemptToNo // Assert await _providerRepository.Received(2).GetByIdAsync(_providerId); - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( "cus_123", Arg.Is(options => options.TaxExempt == TaxExempt.None)); } @@ -907,7 +924,7 @@ public async Task HandleAsync_WhenProviderCustomerIsExempt_DoesNotUpdateTaxExemp var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, null, _providerId)); @@ -918,7 +935,7 @@ public async Task HandleAsync_WhenProviderCustomerIsExempt_DoesNotUpdateTaxExemp await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.DidNotReceive().UpdateCustomer( + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), Arg.Any()); } @@ -949,7 +966,7 @@ public async Task HandleAsync_WhenNonDirectTaxCountryProvider_SetsReverseCharge( var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, null, _providerId)); @@ -959,7 +976,7 @@ public async Task HandleAsync_WhenNonDirectTaxCountryProvider_SetsReverseCharge( await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( Arg.Is("cus_123"), Arg.Is(o => o.TaxExempt == TaxExempt.Reverse)); } @@ -990,7 +1007,7 @@ public async Task HandleAsync_WhenUSProviderWithManualReverseCharge_CorrectsTaxE var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, null, _providerId)); @@ -1000,7 +1017,7 @@ public async Task HandleAsync_WhenUSProviderWithManualReverseCharge_CorrectsTaxE await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateCustomer( + await _stripeAdapter.Received(1).UpdateCustomerAsync( Arg.Is("cus_123"), Arg.Is(o => o.TaxExempt == TaxExempt.None)); } @@ -1048,6 +1065,14 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -1055,7 +1080,7 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) @@ -1067,11 +1092,11 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) .Returns(true); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); // Setup exception when updating subscription - _stripeFacade - .UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception()); // Act @@ -1131,8 +1156,8 @@ public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -1189,8 +1214,8 @@ public async Task HandleAsync_WhenZeroAmountInvoice_DoesNothing() }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -1245,8 +1270,8 @@ public async Task HandleAsync_WhenUserNotFound_DoesNothing() }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -1304,8 +1329,8 @@ public async Task HandleAsync_WhenProviderNotFound_DoesNothing() }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade - .GetCustomer(invoice.CustomerId, Arg.Any()) + _stripeAdapter + .GetCustomerAsync(invoice.CustomerId, Arg.Any()) .Returns(customer); _stripeEventUtilityService @@ -1397,8 +1422,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); - _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1412,7 +1437,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is(subscriptionId), Arg.Is(o => o.Items.Count == 2 && @@ -1424,7 +1449,7 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); - await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => @@ -1444,7 +1469,7 @@ await _mailer.Received(1).SendEmail( )); // Families plan is excluded from tax exempt alignment - await _stripeFacade.DidNotReceive().UpdateCustomer( + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), Arg.Any()); } @@ -1506,7 +1531,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutP }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1520,7 +1545,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutP await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is(subscriptionId), Arg.Is(o => o.Items.Count == 1 && @@ -1539,7 +1564,7 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); // Families plan is excluded from tax exempt alignment - await _stripeFacade.DidNotReceive().UpdateCustomer( + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), Arg.Any()); } @@ -1600,7 +1625,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNot }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1613,7 +1638,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNot await _sut.HandleAsync(parsedEvent); // Assert - should not update subscription or organization when feature flag is disabled - await _stripeFacade.DidNotReceive().UpdateSubscription( + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( Arg.Any(), Arg.Is(o => o.Discounts != null)); @@ -1621,7 +1646,7 @@ await _organizationRepository.DidNotReceive().ReplaceAsync( Arg.Is(org => org.PlanType == PlanType.FamiliesAnnually)); // Families plan is excluded from tax exempt alignment - await _stripeFacade.DidNotReceive().UpdateCustomer( + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), Arg.Any()); } @@ -1677,7 +1702,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesN }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1690,13 +1715,13 @@ public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesN await _sut.HandleAsync(parsedEvent); // Assert - should not update subscription when not on FamiliesAnnually2019 plan - await _stripeFacade.DidNotReceive().UpdateSubscription( + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( Arg.Any(), Arg.Is(o => o.Discounts != null)); await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); // Families plan is excluded from tax exempt alignment - await _stripeFacade.DidNotReceive().UpdateCustomer( + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( Arg.Any(), Arg.Any()); } @@ -1752,7 +1777,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFou }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1775,7 +1800,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFou Arg.Any>()); // Should not update subscription or organization when password manager item not found - await _stripeFacade.DidNotReceive().UpdateSubscription( + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( Arg.Any(), Arg.Is(o => o.Discounts != null)); @@ -1839,7 +1864,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndS }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1850,8 +1875,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndS _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); // Simulate update failure - _stripeFacade - .UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Stripe API error")); // Act @@ -1937,7 +1962,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorA }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1946,8 +1971,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorA _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null); - _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(subscription); // Act @@ -2037,7 +2062,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_Lo }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -2046,8 +2071,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_Lo _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); - _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); - _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(subscription); // Act @@ -2141,8 +2166,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesIt var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); - _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -2156,7 +2181,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesIt await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is(subscriptionId), Arg.Is(o => o.Items.Count == 2 && @@ -2168,7 +2193,7 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); - await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => @@ -2255,8 +2280,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_ var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); - _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -2270,7 +2295,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_ await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is(subscriptionId), Arg.Is(o => o.Items.Count == 2 && @@ -2282,7 +2307,7 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); - await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => @@ -2376,8 +2401,8 @@ public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddO var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); - _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -2391,7 +2416,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddO await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is(subscriptionId), Arg.Is(o => o.Items.Count == 3 && @@ -2405,7 +2430,7 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); - await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount); await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => @@ -2482,7 +2507,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesS }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -2496,7 +2521,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesS await _sut.HandleAsync(parsedEvent); // Assert - await _stripeFacade.Received(1).UpdateSubscription( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( Arg.Is(subscriptionId), Arg.Is(o => o.Items.Count == 1 && @@ -2576,7 +2601,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNot }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -2589,7 +2614,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNot await _sut.HandleAsync(parsedEvent); // Assert - should not update subscription or organization when feature flag is disabled - await _stripeFacade.DidNotReceive().UpdateSubscription( + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( Arg.Any(), Arg.Any()); @@ -2636,6 +2661,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorA Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -2644,14 +2677,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorA }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); - _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null); - _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(subscription); // Act @@ -2715,6 +2748,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_Lo Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -2728,14 +2769,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_Lo }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); - _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); - _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(subscription); // Act @@ -2799,6 +2840,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumR Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -2812,14 +2861,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumR }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); - _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); - _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(subscription); // Act @@ -2881,6 +2930,14 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_ Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; var customer = new Customer { Id = customerId, @@ -2889,15 +2946,15 @@ public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_ }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); - _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(null, _userId, null)); _userRepository.GetByIdAsync(_userId).Returns(user); - _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); - _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount) + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount) .ThrowsAsync(new StripeException("Stripe API error")); - _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) .Returns(subscription); // Act @@ -2924,4 +2981,855 @@ await _mailService.Received(1).SendInvoiceUpcoming( } #endregion + + #region Deferred Price Migration (PM-32645) + + [Fact] + public async Task HandleAsync_Premium_DeferEnabled_CreatesSchedule() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var priceSubscriptionId = "si_premium_123"; + var newPriceId = "premium-annually-2025"; + + var invoice = new Invoice { CustomerId = customerId }; + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = newPriceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] } + }; + var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }; + + var phase1StartDate = DateTime.UtcNow; + var phase1EndDate = DateTime.UtcNow.AddDays(15); + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + Phases = new List + { + new() + { + StartDate = phase1StartDate, + EndDate = phase1EndDate, + Items = new List + { + new() { PriceId = Prices.PremiumAnnually, Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + + await _stripeAdapter.Received(1).CreateSubscriptionScheduleAsync( + Arg.Is(o => + o.FromSubscription == subscriptionId)); + + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + Arg.Is("sub_sched_123"), + Arg.Is(o => + o.EndBehavior == SubscriptionScheduleEndBehavior.Release && + o.Phases.Count == 2 && + o.Phases[0].StartDate == phase1StartDate && + o.Phases[0].EndDate == phase1EndDate && + o.Phases[0].ProrationBehavior == ProrationBehavior.None && + o.Phases[1].StartDate == phase1EndDate && + o.Phases[1].Items[0].Price == newPriceId && + o.Phases[1].Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && + o.Phases[1].ProrationBehavior == ProrationBehavior.None)); + + await _mailer.Received(1).SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Premium_DeferEnabled_ActiveScheduleExists_Skips() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice { CustomerId = customerId }; + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() { Id = "si_1", Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = "premium-annually-2025" }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { SubscriptionId = subscriptionId, Status = SubscriptionScheduleStatus.Active } + } + }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeAdapter.DidNotReceive().CreateSubscriptionScheduleAsync( + Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync( + Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Families2019_DeferEnabled_CreatesScheduleWithDiscount() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() + { + Id = "si_pm_123", + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }, + Quantity = 1 + } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var invoice = new Invoice { CustomerId = customerId }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + + var phase1StartDate = DateTime.UtcNow; + var phase1EndDate = DateTime.UtcNow.AddDays(15); + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_456", + Phases = new List + { + new() + { + StartDate = phase1StartDate, + EndDate = phase1EndDate, + Items = new List + { + new() { PriceId = families2019Plan.PasswordManager.StripePlanId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — schedule created with Phase 2 having families price + Milestone3 discount + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + Arg.Is("sub_sched_456"), + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Phases[1].Discounts != null && + o.Phases[1].Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.Phases[1].ProrationBehavior == ProrationBehavior.None)); + + // Assert — org DB NOT updated (deferred to renewal) + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + + // Assert — no direct subscription update + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + + // Assert — renewal email still sent + await _mailer.Received(1).SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Families2025_DeferEnabled_CreatesScheduleWithoutDiscount() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var families2025Plan = new Families2025Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() + { + Id = "si_pm_123", + Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }, + Quantity = 1 + } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var invoice = new Invoice { CustomerId = customerId }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2025 + }; + + var phase1StartDate = DateTime.UtcNow; + var phase1EndDate = DateTime.UtcNow.AddDays(15); + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_789", + Phases = new List + { + new() + { + StartDate = phase1StartDate, + EndDate = phase1EndDate, + Items = new List + { + new() { PriceId = families2025Plan.PasswordManager.StripePlanId, Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — schedule created with Phase 2 having families price, NO discount + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + Arg.Is("sub_sched_789"), + Arg.Is(o => + o.Phases.Count == 2 && + o.Phases[1].Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Phases[1].Discounts == null && + o.Phases[1].ProrationBehavior == ProrationBehavior.None)); + + // Assert — org DB NOT updated + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + + // Assert — no direct subscription update + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + + // Assert — renewal email still sent + await _mailer.Received(1).SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Families_DeferEnabled_ActiveScheduleExists_Skips() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var families2025Plan = new Families2025Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() + { + Id = "si_pm_123", + Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }, + Quantity = 1 + } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var invoice = new Invoice { CustomerId = customerId }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2025 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(new FamiliesPlan()); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { SubscriptionId = subscriptionId, Status = SubscriptionScheduleStatus.Active } + } + }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeAdapter.DidNotReceive().CreateSubscriptionScheduleAsync( + Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync( + Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Premium_DeferEnabled_ScheduleUpdateFails_ReleasesScheduleAndLogsError() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + Lines = new StripeList { Data = [new() { Description = "Test" }] } + }; + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() { Id = "si_1", Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = "premium-annually-2025" }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] } + }; + + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_123", + Phases = new List + { + new() + { + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddDays(15), + Items = new List + { + new() { PriceId = Prices.PremiumAnnually, Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + _stripeAdapter.UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new StripeException("Stripe API error")); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — orphaned schedule is released + await _stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync("sub_sched_123", null); + + // Assert — no renewal email sent (error path returns false, falls through to traditional email) + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Families_DeferEnabled_ScheduleUpdateFails_ReleasesScheduleAndLogsError() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var families2025Plan = new Families2025Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() + { + Id = "si_pm_123", + Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }, + Quantity = 1 + } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var invoice = new Invoice + { + CustomerId = customerId, + Lines = new StripeList { Data = [new() { Description = "Test" }] } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2025 + }; + + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_456", + Phases = new List + { + new() + { + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddDays(15), + Items = new List + { + new() { PriceId = families2025Plan.PasswordManager.StripePlanId, Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + _stripeAdapter.UpdateSubscriptionScheduleAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new StripeException("Stripe API error")); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — orphaned schedule is released + await _stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync("sub_sched_456", null); + + // Assert — org DB not updated + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Premium_DeferEnabled_ScheduleForDifferentSubscription_StillCreatesSchedule() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice { CustomerId = customerId }; + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() { Id = "si_1", Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = "premium-annually-2025" }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] } + }; + var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }; + + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_new", + Phases = new List + { + new() + { + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddDays(15), + Items = new List + { + new() { PriceId = Prices.PremiumAnnually, Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + + // Return an active schedule for a DIFFERENT subscription on the same customer + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { SubscriptionId = "sub_OTHER", Status = SubscriptionScheduleStatus.Active } + } + }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — schedule creation proceeds despite active schedule for different subscription + await _stripeAdapter.Received(1).CreateSubscriptionScheduleAsync( + Arg.Any()); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_Premium_DeferEnabled_CompletedScheduleExists_StillCreatesSchedule() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice { CustomerId = customerId }; + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() { Id = "si_1", Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = "premium-annually-2025" }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var oldPlan = new PremiumPlan + { + Name = "Premium (Old)", + Available = false, + LegacyYear = 2023, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] } + }; + var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }; + + var schedule = new SubscriptionSchedule + { + Id = "sub_sched_new", + Phases = new List + { + new() + { + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddDays(15), + Items = new List + { + new() { PriceId = Prices.PremiumAnnually, Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 } + }, + Discounts = new List() + } + } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeAdapter.GetCustomerAsync(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.ListPremiumPlans().Returns(new List { oldPlan, plan }); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true); + _stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + + // Return a COMPLETED schedule for the same subscription (from a prior billing cycle) + _stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { SubscriptionId = subscriptionId, Status = SubscriptionScheduleStatus.Completed } + } + }); + _stripeAdapter.CreateSubscriptionScheduleAsync(Arg.Any()) + .Returns(schedule); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert — schedule creation proceeds because existing schedule is not "active" + await _stripeAdapter.Received(1).CreateSubscriptionScheduleAsync( + Arg.Any()); + await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync( + Arg.Any(), Arg.Any()); + } + + #endregion }