diff --git a/src/Api/Models/Response/BillingCustomerDiscount.cs b/src/Api/Models/Response/BillingCustomerDiscount.cs index dc2754519c68..fce10d755224 100644 --- a/src/Api/Models/Response/BillingCustomerDiscount.cs +++ b/src/Api/Models/Response/BillingCustomerDiscount.cs @@ -57,6 +57,13 @@ public class BillingCustomerDiscount /// public IReadOnlyList? AppliesTo { get; } + /// + /// True when this discount was surfaced from a price-migration schedule's Phase 2 coupon + /// rather than a genuine customer- or subscription-level discount. Lets clients distinguish + /// a deferred price-migration coupon from a real discount. + /// + public bool IsFromSchedule { get; } + /// /// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount. /// @@ -73,5 +80,6 @@ public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount End = discount.End; DurationInMonths = discount.DurationInMonths; AppliesTo = discount.AppliesTo; + IsFromSchedule = discount.IsFromSchedule; } } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 747b993cce3a..40eac40c4cd8 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -249,42 +249,54 @@ public class PreviewOrganizationTaxCommand( Quantity = quantity }); - var hasStorage = - subscriptionItemsByPriceId.TryGetValue(newPlan.PasswordManager.StripeStoragePlanId, - out var storage); - - if (hasStorage && storage != null) + // Match existing storage by the CURRENT plan's id (as PM seats/SM do), then re-price at the + // new plan — storage ids can differ across the change (e.g. Families' personal-storage vs an + // org plan's shared storage). Guard: some plans have no storage add-on, so the id can be null. + if (!string.IsNullOrEmpty(currentPlan.PasswordManager.StripeStoragePlanId) && + !string.IsNullOrEmpty(newPlan.PasswordManager.StripeStoragePlanId)) { - items.Add(new InvoiceSubscriptionDetailsItemOptions + var hasStorage = + subscriptionItemsByPriceId.TryGetValue(currentPlan.PasswordManager.StripeStoragePlanId, + out var storage); + + if (hasStorage && storage != null) { - Price = newPlan.PasswordManager.StripeStoragePlanId, - Quantity = storage.Quantity - }); + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storage.Quantity + }); + } } - var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue( - newPlan.SecretsManager.StripeSeatPlanId, - out var secretsManagerSeats); - - if (hasSecretsManagerSeats && secretsManagerSeats != null) + // Match existing SM items by the CURRENT plan's ids (as PM seats/storage do above), then re-price + // at the new plan. Guard: SecretsManager is null for Families/Free (PlanAdapter maps it to null). + if (currentPlan.SecretsManager != null && newPlan.SecretsManager != null) { - items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Price = newPlan.SecretsManager.StripeSeatPlanId, - Quantity = secretsManagerSeats.Quantity - }); - - var hasServiceAccounts = - subscriptionItemsByPriceId.TryGetValue(newPlan.SecretsManager.StripeServiceAccountPlanId, - out var serviceAccounts); + var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue( + currentPlan.SecretsManager.StripeSeatPlanId, + out var secretsManagerSeats); - if (hasServiceAccounts && serviceAccounts != null) + if (hasSecretsManagerSeats && secretsManagerSeats != null) { items.Add(new InvoiceSubscriptionDetailsItemOptions { - Price = newPlan.SecretsManager.StripeServiceAccountPlanId, - Quantity = serviceAccounts.Quantity + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerSeats.Quantity }); + + var hasServiceAccounts = + subscriptionItemsByPriceId.TryGetValue(currentPlan.SecretsManager.StripeServiceAccountPlanId, + out var serviceAccounts); + + if (hasServiceAccounts && serviceAccounts != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccounts.Quantity + }); + } } } diff --git a/src/Core/Billing/Services/Implementations/StripePaymentService.cs b/src/Core/Billing/Services/Implementations/StripePaymentService.cs index 47ace034bd17..091a39c4629d 100644 --- a/src/Core/Billing/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Billing/Services/Implementations/StripePaymentService.cs @@ -815,6 +815,7 @@ private async Task ApplySchedulePhase2DataAsync(Subscription subscription, Subsc if (phase2Discount?.Coupon != null) { subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(phase2Discount.Coupon); + subscriptionInfo.CustomerDiscount.IsFromSchedule = true; } } catch (StripeException ex) diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index c71d735d3dbd..2de68b3c98a8 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -119,6 +119,13 @@ public BillingCustomerDiscount(Coupon coupon) /// /// public IReadOnlyList? AppliesTo { get; set; } + + /// + /// True when this discount was surfaced from a price-migration schedule's Phase 2 coupon + /// rather than a genuine customer- or subscription-level discount. Lets clients distinguish + /// a deferred price-migration coupon from a real discount. Defaults to false. + /// + public bool IsFromSchedule { get; set; } } public class BillingSubscription diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index ad5ca2390e81..bf5cc70e3cce 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -1385,13 +1385,14 @@ public async Task Run_OrganizationPlanChange_ExistingSubscriptionUpgrade_Returns _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); - // Mock existing subscription with items - using NEW plan IDs since command looks for new plan prices + // Mock existing subscription with items keyed by the CURRENT plan's price IDs, as a real + // subscription is. The command re-prices each to the new plan's IDs in the preview. var subscriptionItems = new List { new() { Price = new Price { Id = "2023-teams-org-seat-monthly" }, Quantity = 8 }, - new() { Price = new Price { Id = "storage-gb-annually" }, Quantity = 3 }, - new() { Price = new Price { Id = "secrets-manager-enterprise-seat-annually" }, Quantity = 5 }, - new() { Price = new Price { Id = "secrets-manager-service-account-2024-annually" }, Quantity = 10 } + new() { Price = new Price { Id = "storage-gb-monthly" }, Quantity = 3 }, + new() { Price = new Price { Id = "secrets-manager-teams-seat-monthly" }, Quantity = 5 }, + new() { Price = new Price { Id = "secrets-manager-service-account-2024-monthly" }, Quantity = 10 } }; var subscription = new Subscription @@ -1608,6 +1609,352 @@ public async Task Run_OrganizationPlanChange_OrganizationWithoutGatewayIds_Retur await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); } + // PM-40440 regression: an SM-carrying Teams 2020 annual org upgrading to Enterprise annual must + // still be quoted for its Secrets Manager seats and service accounts. The SM items are looked up + // on the current subscription by the CURRENT plan's price IDs - which differ from the new plan's + // across the tier boundary and the service-account 2024 bump - then re-priced at the new plan. + // Before the fix these lookups used the new plan's IDs, missed, and dropped SM from the total. + [Fact] + public async Task Run_OrganizationPlanChange_Teams2020AnnualWithSecretsManagerToEnterprise_IncludesSecretsManager() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually2020, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = true, + Seats = 10 + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var currentPlan = new Teams2020Plan(true); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // A real Teams 2020 annual subscription with Secrets Manager: SM seats and (legacy) service + // accounts are keyed by the current plan's price IDs. + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2020-teams-org-seat-annually" }, Quantity = 10 }, + new() { Price = new Price { Id = "secrets-manager-teams-seat-annually" }, Quantity = 10 }, + new() { Price = new Price { Id = "secrets-manager-service-account-annually" }, 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 invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], + Total = 0 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + + // SM seats and service accounts are re-priced at the new (Enterprise annual) plan's IDs but keep + // the current subscription's quantities. + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.SubscriptionDetails.Items.Count == 3 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 5))); + } + + // PM-40440 null guard: a Families org's current plan has no Secrets Manager definition + // (Plan.SecretsManager is null), so the SM lookup must be guarded. Families -> Enterprise must not + // throw and must add no SM line. + [Fact] + public async Task Run_OrganizationPlanChange_FamiliesToEnterprise_NoSecretsManagerLineOrThrow() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = true, + Seats = 6 + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var currentPlan = new FamiliesPlan(); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2020-families-org-annually" }, 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 = 0 }], + Total = 0 + }; + + _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.All(item => !item.Price.Contains("secrets-manager")))); + } + + // PM-40440: the SM seat ID is per-tier, so the same drop happens on a monthly tier change. A + // Teams monthly -> Enterprise monthly upgrade must also carry SM through the preview. + [Fact] + public async Task Run_OrganizationPlanChange_TeamsMonthlyWithSecretsManagerToEnterpriseMonthly_IncludesSecretsManager() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Monthly + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var currentPlan = new TeamsPlan(false); + var newPlan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-monthly" }, Quantity = 8 }, + new() { Price = new Price { Id = "secrets-manager-teams-seat-monthly" }, Quantity = 4 }, + new() { Price = new Price { Id = "secrets-manager-service-account-2024-monthly" }, Quantity = 6 } + }; + + 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 = 0 }], + Total = 0 + }; + + _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 == 3 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-seat-monthly" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 4) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 6))); + } + + // PM-40440: an org with no Secrets Manager items on its subscription still previews without an SM + // line (behavior preserved for non-SM orgs). + [Fact] + public async Task Run_OrganizationPlanChange_TeamsAnnualWithoutSecretsManagerToEnterprise_NoSecretsManagerLine() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var currentPlan = new TeamsPlan(true); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-annually" }, 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 invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], + Total = 0 + }; + + _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.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.All(item => !item.Price.Contains("secrets-manager")))); + } + + // PM-40440 (storage): storage ids are not shared across all plans - Families uses + // personal-storage-gb-* while org plans use storage-gb-*. Looking up existing storage by the NEW + // plan's id misses on a Families -> Enterprise upgrade and drops it from the preview. Matching by + // the CURRENT plan's id and re-pricing at the new plan carries the extra storage through. + [Fact] + public async Task Run_OrganizationPlanChange_FamiliesWithStorageToEnterprise_IncludesStorage() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false, + Seats = 6 + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var currentPlan = new FamiliesPlan(); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // A real Families subscription with extra storage: storage is keyed by the Families (personal) + // storage id, which differs from the org plan's storage id. + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 }, + new() { Price = new Price { Id = "personal-storage-gb-annually" }, 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 invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], + Total = 0 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + + // Storage is re-priced at the new (Enterprise) plan's id but keeps the current quantity; the + // Families personal-storage id must not leak into the preview. + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.All(item => item.Price != "personal-storage-gb-annually"))); + } + #endregion #region Subscription Update diff --git a/test/Core.Test/Billing/Services/StripePaymentServiceTests.cs b/test/Core.Test/Billing/Services/StripePaymentServiceTests.cs index 56e109354902..3a53909eff06 100644 --- a/test/Core.Test/Billing/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Billing/Services/StripePaymentServiceTests.cs @@ -70,6 +70,7 @@ public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromC Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); Assert.Equal(20m, result.CustomerDiscount.PercentOff); Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents + Assert.False(result.CustomerDiscount.IsFromSchedule); // Genuine customer discount, not schedule-derived } [Theory] @@ -495,6 +496,7 @@ public async Task GetSubscriptionAsync_WithActiveSchedule_OverridesPricesAndDisc Assert.Equal(CouponIDs.Milestone3SubscriptionDiscount, result.CustomerDiscount.Id); Assert.Equal(25m, result.CustomerDiscount.PercentOff); Assert.True(result.CustomerDiscount.Active); + Assert.True(result.CustomerDiscount.IsFromSchedule); } [Theory]