From f0ec201745b77d136cdb17418f6118b56a823e30 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:38:21 +0100 Subject: [PATCH] [PM 26682]milestone 2d display discount on subscription page (#6542) * The discount badge implementation * Address the claude pr comments * Add more unit testing * Add more test * used existing flag * Add the coupon Ids * Add more code documentation * Add some recommendation from claude * Fix addition comments and prs * Add more integration test * Fix some comment and add more test * rename the test methods * Add more unit test and comments * Resolve the null issues * Add more test * reword the comments * Rename Variable * Some code refactoring * Change the coupon ID to milestone-2c * Fix the failing Test --- .../Billing/Controllers/AccountsController.cs | 30 +- .../Response/SubscriptionResponseModel.cs | 139 ++- src/Core/Billing/Constants/StripeConstants.cs | 2 +- src/Core/Models/Business/SubscriptionInfo.cs | 116 ++- .../Implementations/StripePaymentService.cs | 14 +- .../Controllers/AccountsControllerTests.cs | 800 ++++++++++++++++++ .../SubscriptionResponseModelTests.cs | 400 +++++++++ .../Business/BillingCustomerDiscountTests.cs | 497 +++++++++++ .../Models/Business/SubscriptionInfoTests.cs | 125 +++ .../Services/StripePaymentServiceTests.cs | 396 +++++++++ 10 files changed, 2460 insertions(+), 59 deletions(-) create mode 100644 test/Api.Test/Billing/Controllers/AccountsControllerTests.cs create mode 100644 test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs create mode 100644 test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs create mode 100644 test/Core.Test/Models/Business/SubscriptionInfoTests.cs diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 9dbe4a5532..075218dd74 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -4,6 +4,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; @@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers; public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IUserAccountKeysQuery userAccountKeysQuery) : Controller + IUserAccountKeysQuery userAccountKeysQuery, + IFeatureService featureService) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -84,16 +86,24 @@ public class AccountsController( throw new UnauthorizedAccessException(); } - if (!globalSettings.SelfHosted && user.Gateway != null) + // Only cloud-hosted users with payment gateways have subscription and discount information + if (!globalSettings.SelfHosted) { - var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); - var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license); - } - else if (!globalSettings.SelfHosted) - { - var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + if (user.Gateway != null) + { + // Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341). + // This specific implementation (PM-26682) adds discount display functionality as part of that initiative. + // The feature flag controls the broader Milestone 2 feature set, not just this specific task. + var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); + var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); + return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + } + else + { + var license = await userService.GenerateLicenseAsync(user); + return new SubscriptionResponseModel(user, license); + } } else { diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 7038bee2a7..29a47e160c 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,6 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response; public class SubscriptionResponseModel : ResponseModel { - public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license) + + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false) : base("subscription") { Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; @@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel MaxStorageGb = user.MaxStorageGb; License = license; Expiration = License.Expires; + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount) + ? new BillingCustomerDiscount(subscription.CustomerDiscount!) + : null; } - public SubscriptionResponseModel(User user, UserLicense license = null) + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; @@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel } } - public string StorageName { get; set; } + public string? StorageName { get; set; } public double? StorageGb { get; set; } public short? MaxStorageGb { get; set; } - public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } - public BillingSubscription Subscription { get; set; } - public UserLicense License { get; set; } + public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; } + public BillingSubscription? Subscription { get; set; } + /// + /// Customer discount information from Stripe for the Milestone 2 subscription discount. + /// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration). + /// This is for display purposes only and does not affect Stripe's automatic discount application. + /// Other discounts may still apply in Stripe billing but are not included in this response. + /// + /// Null when: + /// - The PM23341_Milestone_2 feature flag is disabled + /// - There is no active discount + /// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1) + /// - The instance is self-hosted + /// + /// + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public UserLicense? License { get; set; } public DateTime? Expiration { get; set; } + + /// + /// Determines whether the Milestone 2 discount should be included in the response. + /// + /// Whether the feature flag is enabled and discount should be considered. + /// The customer discount from subscription info, if any. + /// True if the discount should be included; false otherwise. + private static bool ShouldIncludeMilestone2Discount( + bool includeMilestone2Discount, + SubscriptionInfo.BillingCustomerDiscount? customerDiscount) + { + return includeMilestone2Discount && + customerDiscount != null && + customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount && + customerDiscount.Active; + } } -public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) +/// +/// Customer discount information from Stripe billing. +/// +public class BillingCustomerDiscount { - public string Id { get; } = discount.Id; - public bool Active { get; } = discount.Active; - public decimal? PercentOff { get; } = discount.PercentOff; - public List AppliesTo { get; } = discount.AppliesTo; + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// + public string? Id { get; } + + /// + /// Whether the discount is a recurring/perpetual discount with no expiration date. + /// + /// This property is true only when the discount has no end date, meaning it applies + /// indefinitely to all future renewals. This is a product decision for Milestone 2 + /// to only display perpetual discounts in the UI. + /// + /// + /// Note: This does NOT indicate whether the discount is "currently active" in the billing sense. + /// A discount with a future end date is functionally active and will be applied by Stripe, + /// but this property will be false because it has an expiration date. + /// + /// + public bool Active { get; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// + public decimal? PercentOff { get; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD. + /// + public decimal? AmountOff { get; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; } + + /// + /// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount. + /// + /// The discount to convert. Must not be null. + /// Thrown when discount is null. + public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) + { + ArgumentNullException.ThrowIfNull(discount); + + Id = discount.Id; + Active = discount.Active; + PercentOff = discount.PercentOff; + AmountOff = discount.AmountOff; + AppliesTo = discount.AppliesTo; + } } public class BillingSubscription @@ -83,10 +184,10 @@ public class BillingSubscription public DateTime? PeriodEndDate { get; set; } public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int? GracePeriod { get; set; } @@ -104,11 +205,11 @@ public class BillingSubscription AddonSubscriptionItem = item.AddonSubscriptionItem; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } public bool AddonSubscriptionItem { get; set; } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 517273db4e..9cfb4e9b0d 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,7 +22,7 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; - public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; + public const string Milestone2SubscriptionDiscount = "milestone-2c"; public static class MSPDiscounts { diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index f8a96a189f..be514cb39f 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -1,58 +1,118 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Extensions; using Stripe; +#nullable enable + namespace Bit.Core.Models.Business; public class SubscriptionInfo { - public BillingCustomerDiscount CustomerDiscount { get; set; } - public BillingSubscription Subscription { get; set; } - public BillingUpcomingInvoice UpcomingInvoice { get; set; } + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only. + /// + private const decimal StripeMinorUnitDivisor = 100M; + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m). + /// + /// The amount in Stripe's minor currency units (e.g., cents for USD). + /// The amount in major currency units (e.g., dollars for USD), or null if the input is null. + private static decimal? ConvertFromStripeMinorUnits(long? amountInCents) + { + return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null; + } + + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public BillingSubscription? Subscription { get; set; } + public BillingUpcomingInvoice? UpcomingInvoice { get; set; } + + /// + /// Represents customer discount information from Stripe billing. + /// public class BillingCustomerDiscount { public BillingCustomerDiscount() { } + /// + /// Creates a BillingCustomerDiscount from a Stripe Discount object. + /// + /// The Stripe discount containing coupon and expiration information. public BillingCustomerDiscount(Discount discount) { Id = discount.Coupon?.Id; + // Active = true only for perpetual/recurring discounts (no end date) + // This is intentional for Milestone 2 - only perpetual discounts are shown in UI Active = discount.End == null; PercentOff = discount.Coupon?.PercentOff; - AppliesTo = discount.Coupon?.AppliesTo?.Products ?? []; + AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff); + // Stripe's CouponAppliesTo.Products is already IReadOnlyList, so no conversion needed + AppliesTo = discount.Coupon?.AppliesTo?.Products; } - public string Id { get; set; } + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration, + /// though Stripe may apply additional discounts that are not shown. + /// + public string? Id { get; set; } + + /// + /// True only for perpetual/recurring discounts (End == null). + /// False for any discount with an expiration date, even if not yet expired. + /// Product decision for Milestone 2: only show perpetual discounts in UI. + /// public bool Active { get; set; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// public decimal? PercentOff { get; set; } - public List AppliesTo { get; set; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// + public decimal? AmountOff { get; set; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; set; } } public class BillingSubscription { public BillingSubscription(Subscription sub) { - Status = sub.Status; - TrialStartDate = sub.TrialStart; - TrialEndDate = sub.TrialEnd; - var currentPeriod = sub.GetCurrentPeriod(); + Status = sub?.Status; + TrialStartDate = sub?.TrialStart; + TrialEndDate = sub?.TrialEnd; + var currentPeriod = sub?.GetCurrentPeriod(); if (currentPeriod != null) { var (start, end) = currentPeriod.Value; PeriodStartDate = start; PeriodEndDate = end; } - CancelledDate = sub.CanceledAt; - CancelAtEndDate = sub.CancelAtPeriodEnd; - Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired"; - if (sub.Items?.Data != null) + CancelledDate = sub?.CanceledAt; + CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false; + var status = sub?.Status; + Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired"; + if (sub?.Items?.Data != null) { Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); } - CollectionMethod = sub.CollectionMethod; - GracePeriod = sub.CollectionMethod == "charge_automatically" + CollectionMethod = sub?.CollectionMethod; + GracePeriod = sub?.CollectionMethod == "charge_automatically" ? 14 : 30; } @@ -64,10 +124,10 @@ public class SubscriptionInfo public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate; public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int GracePeriod { get; set; } @@ -80,7 +140,7 @@ public class SubscriptionInfo { ProductId = item.Plan.ProductId; Name = item.Plan.Nickname; - Amount = item.Plan.Amount.GetValueOrDefault() / 100M; + Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0; Interval = item.Plan.Interval; if (item.Metadata != null) @@ -90,15 +150,15 @@ public class SubscriptionInfo } Quantity = (int)item.Quantity; - SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); + SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); } public bool AddonSubscriptionItem { get; set; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } } } @@ -109,7 +169,7 @@ public class SubscriptionInfo public BillingUpcomingInvoice(Invoice inv) { - Amount = inv.AmountDue / 100M; + Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0; Date = inv.Created; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ff99393955..5dd1ff50e7 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); + if (subscription == null) + { + return subscriptionInfo; + } + subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); - var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault(); + // Discount selection priority: + // 1. Customer-level discount (applies to all subscriptions for the customer) + // 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one) + // Note: When multiple subscription-level discounts exist, only the first one is used. + // This matches Stripe's behavior where the first discount in the list is applied. + // Defensive null checks: Even though we expand "customer" and "discounts", external APIs + // may not always return the expected data structure, so we use null-safe operators. + var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault(); if (discount != null) { diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs new file mode 100644 index 0000000000..d84fddd282 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -0,0 +1,800 @@ +using System.Security.Claims; +using Bit.Api.Billing.Controllers; +using Bit.Core; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Models.Business; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Test.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers; + +[SubscriptionInfoCustomize] +public class AccountsControllerTests : IDisposable +{ + private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount; + + private readonly IUserService _userService; + private readonly IFeatureService _featureService; + private readonly IPaymentService _paymentService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly GlobalSettings _globalSettings; + private readonly AccountsController _sut; + + public AccountsControllerTests() + { + _userService = Substitute.For(); + _featureService = Substitute.For(); + _paymentService = Substitute.For(); + _twoFactorIsEnabledQuery = Substitute.For(); + _userAccountKeysQuery = Substitute.For(); + _globalSettings = new GlobalSettings { SelfHosted = false }; + + _sut = new AccountsController( + _userService, + _twoFactorIsEnabledQuery, + _userAccountKeysQuery, + _featureService + ); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user) + { + // Arrange + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license) + { + // Arrange + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when no gateway + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a Stripe Discount object with real structure + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 1400, // 1400 cents = $14.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null // Active discount + }; + + // Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does) + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify full pipeline conversion + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe data correctly converted to API response + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + + // Verify cents-to-dollars conversion (1400 cents -> $14.00) + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + + // Verify AppliesTo products are preserved + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility( + User user, + UserLicense license) + { + // Arrange - Create Stripe Discount + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m + }, + End = null + }; + + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act & Assert - Feature flag ENABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.NotNull(resultWithFlag.CustomerDiscount); + + // Act & Assert - Feature flag DISABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.Null(resultWithoutFlag.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a real Stripe Discount object as it would come from Stripe API + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families", "prod_teams" } + } + }, + End = null // Active discount (no end date) + }; + + // Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount + // This simulates what StripePaymentService.GetSubscriptionAsync does + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + // Verify the mapping worked correctly + Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id); + Assert.True(billingCustomerDiscount.Active); + Assert.Equal(30m, billingCustomerDiscount.PercentOff); + Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents + Assert.NotNull(billingCustomerDiscount.AppliesTo); + Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count); + + // Step 2: Create SubscriptionInfo with the mapped discount + // This simulates what StripePaymentService returns + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + // Step 3: Set up controller dependencies + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Step 4: Call AccountsController.GetSubscriptionAsync + // This exercises the complete pipeline: + // - Retrieves subscriptionInfo from paymentService (with discount from Stripe) + // - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above) + // - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status) + // - Returns via AccountsController + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify the complete pipeline worked end-to-end + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping + // (verified above, but confirming it made it through) + + // Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering + // The filter should pass because: + // - includeMilestone2Discount = true (feature flag enabled) + // - subscription.CustomerDiscount != null + // - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount + // - subscription.CustomerDiscount.Active = true + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion + + // Verify AppliesTo products are preserved through the entire pipeline + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo); + + // Verify the payment service was called correctly + await _paymentService.Received(1).GetSubscriptionAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with multiple discounts + // Customer discount should be preferred over subscription discounts + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = null + }, + End = null + }; + + var subscriptionDiscount1 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-1", + PercentOff = 10m + }, + End = null + }; + + var subscriptionDiscount2 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-2", + PercentOff = 15m + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should use customer discount, not subscription discounts + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff + // This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232 + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium" } + } + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Both values should be preserved through the pipeline + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with subscription details + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + TrialStart = DateTime.UtcNow.AddDays(-30), + TrialEnd = DateTime.UtcNow.AddDays(-20), + CanceledAt = null, + CancelAtPeriodEnd = false, + CollectionMethod = "charge_automatically" + }; + + // Map through SubscriptionInfo.BillingSubscription + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var subscriptionInfo = new SubscriptionInfo + { + Subscription = billingSubscription, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingSubscription mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe invoice for upcoming invoice + var stripeInvoice = new Invoice + { + AmountDue = 2000, // 2000 cents = $20.00 + Created = DateTime.UtcNow.AddDays(1) + }; + + // Map through SubscriptionInfo.BillingUpcomingInvoice + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + var subscriptionInfo = new SubscriptionInfo + { + UpcomingInvoice = billingUpcomingInvoice, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingUpcomingInvoice mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents( + User user, + UserLicense license) + { + // Arrange - Complete Stripe objects for full pipeline test + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m, + AmountOff = 1000, // $10.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null + }; + + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + // Map through SubscriptionInfo (simulating StripePaymentService) + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount, + Subscription = billingSubscription, + UpcomingInvoice = billingUpcomingInvoice + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify all components mapped correctly through the pipeline + Assert.NotNull(result); + + // Verify discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(10.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + + // Verify subscription + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); + + // Verify upcoming invoice + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user) + { + // Arrange - Self-hosted user with discount flag enabled (should still return null) + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert - Should never include discount for self-hosted, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount( + User user, + UserLicense license) + { + // Arrange - User with null gateway and discount flag enabled (should still return null) + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should never include discount when no gateway, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } +} diff --git a/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs new file mode 100644 index 0000000000..051a66bbd3 --- /dev/null +++ b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs @@ -0,0 +1,400 @@ +using Bit.Api.Models.Response; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class SubscriptionResponseModelTests +{ + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Null(result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false); + + // Assert - Should be null because includeMilestone2Discount is false + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullCustomerDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = null + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = null, + AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount + AppliesTo = new List() + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Null(result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act - Using default parameter (includeMilestone2Discount defaults to false) + var result = new SubscriptionResponseModel(user, subscriptionInfo, license); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = null, // Null discount ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserOnly_SetsBasicProperties(User user) + { + // Arrange + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = (short)10; + user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12); + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.NotNull(result.StorageName); + Assert.Equal(5.0, result.StorageGb); + Assert.Equal((short)10, result.MaxStorageGb); + Assert.Equal(user.PremiumExpirationDate, result.Expiration); + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license) + { + // Arrange + user.Storage = 1073741824; // 1 GB in bytes + user.MaxStorageGb = (short)5; + + // Act + var result = new SubscriptionResponseModel(user, license); + + // Assert + Assert.NotNull(result.License); + Assert.Equal(license, result.License); + Assert.Equal(1.0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullStorage_SetsStorageToZero(User user) + { + // Arrange + user.Storage = null; + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.Null(result.StorageName); + Assert.Equal(0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullLicense_ExcludesLicense(User user) + { + // Act + var result = new SubscriptionResponseModel(user, null); + + // Assert + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Both PercentOff and AmountOff present + // This tests the scenario where Stripe coupon has both discount types + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 25m, + AmountOff = 20.00m, // Already converted from cents + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Both values should be preserved + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties( + User user, + UserLicense license) + { + // Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // 1500 cents = $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + var subscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription), + UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice), + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Verify all properties are mapped correctly + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully( + User user, + UserLicense license) + { + // Arrange - Test with null Subscription and UpcomingInvoice + var subscriptionInfo = new SubscriptionInfo + { + Subscription = null, + UpcomingInvoice = null, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Null Subscription and UpcomingInvoice should be handled gracefully + Assert.Null(result.Subscription); + Assert.Null(result.UpcomingInvoice); + Assert.NotNull(result.CustomerDiscount); + } +} diff --git a/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs new file mode 100644 index 0000000000..6dbe829da5 --- /dev/null +++ b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs @@ -0,0 +1,497 @@ +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class BillingCustomerDiscountTests +{ + [Theory] + [BitAutoData] + public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 25.5m, + AmountOff = null, + AppliesTo = new CouponAppliesTo + { + Products = new List { "product1", "product2" } + } + }, + End = null // Active discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Equal(25.5m, result.PercentOff); + Assert.Null(result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(2, result.AppliesTo.Count); + Assert.Contains("product1", result.AppliesTo); + Assert.Contains("product2", result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId) + { + // Arrange - Stripe sends 1400 cents for $14.00 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1400, // 1400 cents + AppliesTo = new CouponAppliesTo + { + Products = new List() + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Equal(14.00m, result.AmountOff); // Converted to dollars + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 15m + }, + End = DateTime.UtcNow.AddDays(-1) // Expired discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.False(result.Active); + Assert.Equal(15m, result.PercentOff); + } + + [Fact] + public void Constructor_NullCoupon_SetsDiscountPropertiesToNull() + { + // Arrange + var discount = new Discount + { + Coupon = null, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AmountOff = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 0 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $100.00 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 10000 // 10000 cents = $100.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(100.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $0.50 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 50 // 50 cents = $0.50 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0.50m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId) + { + // Arrange - Coupon with both percentage and amount (edge case) + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m, + AmountOff = 500 // $5.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(20m, result.PercentOff); + Assert.Equal(5.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId) + { + // Arrange - 1425 cents = $14.25 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 1425 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(14.25m, result.AmountOff); + } + + [Fact] + public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse() + { + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(); + + // Assert + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount expires in the future + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(30) // Expires in 30 days + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Theory] + [BitAutoData] + public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount already expired + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Fact] + public void Constructor_WithNullCouponId_SetsIdToNull() + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = 20m + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Equal(20m, result.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1000 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.PercentOff); + Assert.Equal(10.00m, result.AmountOff); + } + + [Fact] + public void Constructor_WithCompleteStripeDiscount_MapsAllProperties() + { + // Arrange - Comprehensive test with all Stripe Discount properties set + var discount = new Discount + { + Coupon = new Coupon + { + Id = "premium_discount_2024", + PercentOff = 25m, + AmountOff = 1500, // $15.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_family", "prod_teams" } + } + }, + End = null // Active + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Verify all properties mapped correctly + Assert.Equal("premium_discount_2024", result.Id); + Assert.True(result.Active); + Assert.Equal(25m, result.PercentOff); + Assert.Equal(15.00m, result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(3, result.AppliesTo.Count); + Assert.Contains("prod_premium", result.AppliesTo); + Assert.Contains("prod_family", result.AppliesTo); + Assert.Contains("prod_teams", result.AppliesTo); + } + + [Fact] + public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully() + { + // Arrange - Minimal Stripe Discount with most properties null + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = null, + AmountOff = null, + AppliesTo = null + }, + End = DateTime.UtcNow.AddDays(10) // Has end date + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Should handle all nulls gracefully + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = new List() // Empty but not null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } +} diff --git a/test/Core.Test/Models/Business/SubscriptionInfoTests.cs b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs new file mode 100644 index 0000000000..ef6a61ad5d --- /dev/null +++ b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs @@ -0,0 +1,125 @@ +using Bit.Core.Models.Business; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class SubscriptionInfoTests +{ + [Fact] + public void BillingSubscriptionItem_NullPlan_HandlesGracefully() + { + // Arrange - SubscriptionItem with null Plan + var subscriptionItem = new SubscriptionItem + { + Plan = null, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should handle null Plan gracefully + Assert.Null(result.ProductId); + Assert.Null(result.Name); + Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null + Assert.Null(result.Interval); + Assert.Equal(1, result.Quantity); + Assert.False(result.SponsoredSubscriptionItem); + Assert.False(result.AddonSubscriptionItem); + } + + [Fact] + public void BillingSubscriptionItem_NullAmount_SetsToZero() + { + // Arrange - SubscriptionItem with Plan but null Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = null, // Null amount + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should default to 0 when Amount is null + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null + Assert.Equal("month", result.Interval); + Assert.Equal(1, result.Quantity); + } + + [Fact] + public void BillingSubscriptionItem_ZeroAmount_PreservesZero() + { + // Arrange - SubscriptionItem with Plan and zero Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = 0, // Zero amount (0 cents) + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should preserve zero amount + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Zero amount preserved + Assert.Equal("month", result.Interval); + } + + [Fact] + public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero() + { + // Arrange - Invoice with zero AmountDue + // Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0 + // The null-coalescing operator (?? 0) in the constructor handles the case where + // ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable, + // this test verifies the conversion path works correctly for zero values + var invoice = new Invoice + { + AmountDue = 0, // Zero amount due (0 cents) + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert zero correctly + Assert.Equal(0m, result.Amount); + Assert.NotNull(result.Date); + } + + [Fact] + public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly() + { + // Arrange - Invoice with valid AmountDue + var invoice = new Invoice + { + AmountDue = 2500, // 2500 cents = $25.00 + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert correctly + Assert.Equal(25.00m, result.Amount); // Converted from cents + Assert.NotNull(result.Date); + } +} + diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index dd342bd153..863fe716d4 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Requests; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -515,4 +516,399 @@ public class StripePaymentServiceTests options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse )); } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 20m, + AmountOff = 1400 + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = customerDiscount + }, + Discounts = new List(), // Empty list + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscriptionDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 15m, + AmountOff = null + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + Discounts = new List { subscriptionDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should use subscription discount as fallback + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(15m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 25m + }, + End = null + }; + + var subscriptionDiscount = new Discount + { + Coupon = new Coupon + { + Id = "different-coupon-id", + PercentOff = 10m + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = customerDiscount // Should prefer this + }, + Discounts = new List { subscriptionDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should prefer customer discount over subscription discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null + }, + Discounts = new List(), // Empty list, no discounts + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Multiple subscription-level discounts, no customer discount + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var firstDiscount = new Discount + { + Coupon = new Coupon + { + Id = "coupon-10-percent", + PercentOff = 10m + }, + End = null + }; + + var secondDiscount = new Discount + { + Coupon = new Coupon + { + Id = "coupon-20-percent", + PercentOff = 20m + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + // Multiple subscription discounts - FirstOrDefault() should select the first one + Discounts = new List { firstDiscount, secondDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should select the first discount from the list (FirstOrDefault() behavior) + Assert.NotNull(result.CustomerDiscount); + Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id); + Assert.Equal(10m, result.CustomerDiscount.PercentOff); + // Verify the second discount was not selected + Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id); + Assert.NotEqual(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Subscription with null Customer (defensive null check scenario) + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = null, // Customer not expanded or null + Discounts = new List(), // Empty discounts + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should handle null Customer gracefully without throwing NullReferenceException + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Subscription with null Discounts (defensive null check scenario) + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + Discounts = null, // Discounts not expanded or null + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should handle null Discounts gracefully without throwing NullReferenceException + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer { Discount = null }, + Discounts = new List(), // Empty list + Items = new StripeList { Data = [] } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .SubscriptionGetAsync( + Arg.Any(), + Arg.Any()) + .Returns(subscription); + + // Act + await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Verify expand options are correct + await stripeAdapter.Received(1).SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Is(o => + o.Expand.Contains("customer.discount.coupon.applies_to") && + o.Expand.Contains("discounts.coupon.applies_to") && + o.Expand.Contains("test_clock"))); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.GatewaySubscriptionId = null; + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.NotNull(result); + Assert.Null(result.Subscription); + Assert.Null(result.CustomerDiscount); + Assert.Null(result.UpcomingInvoice); + + // Verify no Stripe API calls were made + await sutProvider.GetDependency() + .DidNotReceive() + .SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } }