diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 747b993cce3a..72164d3f2802 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -45,6 +45,8 @@ public class PreviewOrganizationTaxCommand( ISubscriptionDiscountService subscriptionDiscountService) : BaseBillingCommand(logger), IPreviewOrganizationTaxCommand { + private readonly ILogger _logger = logger; + public Task> Run( User user, OrganizationSubscriptionPurchase purchase, @@ -231,15 +233,34 @@ public class PreviewOrganizationTaxCommand( var items = new List(); - var passwordManagerSeats = subscriptionItemsByPriceId[ - currentPlan.HasNonSeatBasedPasswordManagerPlan() + long quantity; + + if (currentPlan.HasNonSeatBasedPasswordManagerPlan() && !newPlan.HasNonSeatBasedPasswordManagerPlan()) + { + // The current plan doesn't have a per-seat subscription item to read a quantity from + // (e.g. upgrading from a flat-rate plan like Teams Starter), so fall back to the + // organization's occupied seat count instead of looking it up on the subscription. + quantity = (long)organization.Seats!; + } + else + { + var passwordManagerPriceId = currentPlan.HasNonSeatBasedPasswordManagerPlan() ? currentPlan.PasswordManager.StripePlanId - : currentPlan.PasswordManager.StripeSeatPlanId]; + : currentPlan.PasswordManager.StripeSeatPlanId; - var quantity = currentPlan.HasNonSeatBasedPasswordManagerPlan() && - !newPlan.HasNonSeatBasedPasswordManagerPlan() - ? (long)organization.Seats! - : passwordManagerSeats.Quantity; + if (!subscriptionItemsByPriceId.TryGetValue(passwordManagerPriceId, out var passwordManagerSeats)) + { + _logger.LogError( + "Organization {OrganizationId}'s subscription {SubscriptionId} does not contain a " + + "Password Manager line item matching its current plan's price {PriceId}", + organization.Id, organization.GatewaySubscriptionId, passwordManagerPriceId); + + return new BadRequest( + "Your organization's subscription does not match its current plan. Please contact support for assistance."); + } + + quantity = passwordManagerSeats.Quantity; + } items.Add(new InvoiceSubscriptionDetailsItemOptions { diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index ad5ca2390e81..b5f28210c828 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -1494,6 +1494,132 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is + { + new() { Price = new Price { Id = currentPlan.PasswordManager.StripePlanId }, Quantity = 1 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 900 }], + Total = 9900 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == newPlan.PasswordManager.StripeSeatPlanId && + options.SubscriptionDetails.Items[0].Quantity == organization.Seats)); + } + + // Regression test: if the organization's current plan is seat-based but its subscription does not + // contain a line item matching that plan's price (a data discrepancy), the command should return a + // BadRequest rather than throwing a KeyNotFoundException from the dictionary lookup. + [Fact] + public async Task Run_OrganizationPlanChange_SubscriptionMissingCurrentPlanPriceLineItem_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly2023, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false, + Seats = 5 + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var currentPlan = new Teams2023Plan(false); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // The subscription does not contain a line item for the current plan's Password Manager price. + var subscriptionItems = new List + { + new() { Price = new Price { Id = "some-other-unrelated-price" }, Quantity = 5 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal( + "Your organization's subscription does not match its current plan. Please contact support for assistance.", + badRequest.Response); + + await _stripeAdapter.DidNotReceive().CreateInvoicePreviewAsync(Arg.Any()); + } + [Fact] public async Task Run_OrganizationPlanChange_ExistingSubscriptionWithDiscount_PreservesCoupon() {