diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index fd54d69db789..c6e874e2f735 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -1,15 +1,11 @@ -using Bit.Api.Models.Request; -using Bit.Api.Models.Request.Accounts; +using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Api.Utilities; -using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Exceptions; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,50 +15,11 @@ namespace Bit.Api.Billing.Controllers; [Route("accounts")] [Authorize("Application")] public class AccountsController( - IUserService userService, - IFeatureService featureService, - ILicensingService licensingService, - IReinstateSubscriptionCommand reinstateSubscriptionCommand) : Controller + IUserService userService) : Controller { - // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpGet("subscription")] - public async Task GetSubscriptionAsync( - [FromServices] GlobalSettings globalSettings, - [FromServices] IStripePaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - // Only cloud-hosted users with payment gateways have subscription and discount information - if (!globalSettings.SelfHosted) - { - if (user.Gateway != null) - { - var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); - var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); - return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal); - } - else - { - var license = await userService.GenerateLicenseAsync(user); - var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); - return new SubscriptionResponseModel(user, null, license, claimsPrincipal); - } - } - else - { - return new SubscriptionResponseModel(user); - } - } - - // TODO: Remove with deletion of pm-29594-update-individual-subscription-page - [HttpPost("storage")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostStorageAsync([FromBody] StorageRequestModel model) + [SelfHosted(SelfHostedOnly = true)] + public async Task GetSubscriptionAsync() { var user = await userService.GetUserByPrincipalAsync(User); if (user == null) @@ -70,8 +27,7 @@ public async Task PostStorageAsync([FromBody] StorageReque throw new UnauthorizedAccessException(); } - var result = await userService.AdjustStorageAsync(user, model.StorageGbAdjustment!.Value); - return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; + return new SubscriptionResponseModel(user); } /* @@ -115,25 +71,4 @@ await subscriberService.CancelSubscription(user, user.IsExpired(), new OffboardingSurveyResponse { UserId = user.Id, Reason = request.Reason, Feedback = request.Feedback }); } - - // TODO: Remove with deletion of pm-29594-update-individual-subscription-page - [HttpPost("reinstate-premium")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostReinstateAsync() - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal)) - { - (await reinstateSubscriptionCommand.Run(user)).GetValueOrThrow(); - } - else - { - await userService.ReinstatePremiumAsync(user); - } - } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 3962abdc9398..76d14cce50c4 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -119,7 +119,6 @@ public async Task GetLicenseAsync( } [HttpGet("subscription")] - [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] [InjectUser] public async Task GetSubscriptionAsync( [BindNever] User user) @@ -129,7 +128,6 @@ public async Task GetSubscriptionAsync( } [HttpPost("subscription/reinstate")] - [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] [InjectUser] public async Task ReinstateSubscriptionAsync( [BindNever] User user) @@ -139,7 +137,6 @@ public async Task ReinstateSubscriptionAsync( } [HttpPut("subscription/storage")] - [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] [InjectUser] public async Task UpdateSubscriptionStorageAsync( [BindNever] User user, diff --git a/src/Api/Models/Response/BillingCustomerDiscount.cs b/src/Api/Models/Response/BillingCustomerDiscount.cs new file mode 100644 index 000000000000..ff2c86236753 --- /dev/null +++ b/src/Api/Models/Response/BillingCustomerDiscount.cs @@ -0,0 +1,69 @@ +using Bit.Core.Models.Business; + +namespace Bit.Api.Models.Response; + +/// +/// Customer discount information from Stripe billing. +/// +public class BillingCustomerDiscount +{ + /// + /// 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; + } +} diff --git a/src/Api/Models/Response/BillingSubscription.cs b/src/Api/Models/Response/BillingSubscription.cs new file mode 100644 index 000000000000..639343fc232e --- /dev/null +++ b/src/Api/Models/Response/BillingSubscription.cs @@ -0,0 +1,62 @@ +using Bit.Core.Models.Business; + +namespace Bit.Api.Models.Response; + +public class BillingSubscription +{ + public BillingSubscription(SubscriptionInfo.BillingSubscription sub) + { + Status = sub.Status; + TrialStartDate = sub.TrialStartDate; + TrialEndDate = sub.TrialEndDate; + PeriodStartDate = sub.PeriodStartDate; + PeriodEndDate = sub.PeriodEndDate; + CancelledDate = sub.CancelledDate; + CancelAtEndDate = sub.CancelAtEndDate; + Cancelled = sub.Cancelled; + if (sub.Items != null) + { + Items = sub.Items.Select(i => new BillingSubscriptionItem(i)); + } + CollectionMethod = sub.CollectionMethod; + SuspensionDate = sub.SuspensionDate; + UnpaidPeriodEndDate = sub.UnpaidPeriodEndDate; + GracePeriod = sub.GracePeriod; + } + + public DateTime? TrialStartDate { get; set; } + public DateTime? TrialEndDate { get; set; } + public DateTime? PeriodStartDate { get; set; } + public DateTime? PeriodEndDate { get; set; } + public DateTime? CancelledDate { get; set; } + public bool CancelAtEndDate { 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 DateTime? SuspensionDate { get; set; } + public DateTime? UnpaidPeriodEndDate { get; set; } + public int? GracePeriod { get; set; } + + public class BillingSubscriptionItem + { + public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubscriptionItem item) + { + ProductId = item.ProductId; + Name = item.Name; + Amount = item.Amount; + Interval = item.Interval; + Quantity = item.Quantity; + SponsoredSubscriptionItem = item.SponsoredSubscriptionItem; + AddonSubscriptionItem = item.AddonSubscriptionItem; + } + + 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 bool SponsoredSubscriptionItem { get; set; } + public bool AddonSubscriptionItem { get; set; } + } +} diff --git a/src/Api/Models/Response/BillingSubscriptionUpcomingInvoice.cs b/src/Api/Models/Response/BillingSubscriptionUpcomingInvoice.cs new file mode 100644 index 000000000000..3f834c526c33 --- /dev/null +++ b/src/Api/Models/Response/BillingSubscriptionUpcomingInvoice.cs @@ -0,0 +1,15 @@ +using Bit.Core.Models.Business; + +namespace Bit.Api.Models.Response; + +public class BillingSubscriptionUpcomingInvoice +{ + public BillingSubscriptionUpcomingInvoice(SubscriptionInfo.BillingUpcomingInvoice inv) + { + Amount = inv.Amount; + Date = inv.Date; + } + + public decimal? Amount { get; set; } + public DateTime? Date { get; set; } +} diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 1425e5a89ca7..d039da8ed999 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,253 +1,22 @@ -using System.Security.Claims; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Licenses; -using Bit.Core.Billing.Licenses.Extensions; -using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; -using Bit.Core.Models.Business; using Bit.Core.Utilities; namespace Bit.Api.Models.Response; -// TODO: Remove with deletion of pm-29594-update-individual-subscription-page public class SubscriptionResponseModel : ResponseModel { - - /// 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 - public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license) - : base("subscription") - { - Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; - UpcomingInvoice = subscription.UpcomingInvoice != null ? - new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; - StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; - StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB - MaxStorageGb = user.MaxStorageGb; - License = license; - Expiration = License.Expires; - - CustomerDiscount = ShouldIncludeDiscount(subscription.CustomerDiscount) - ? new BillingCustomerDiscount(subscription.CustomerDiscount!) - : null; - } - - /// 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 - /// The claims principal containing cryptographically secure token claims - public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal) - : base("subscription") - { - Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; - UpcomingInvoice = subscription?.UpcomingInvoice != null ? - new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; - StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; - StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB - MaxStorageGb = user.MaxStorageGb; - License = license; - - // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim - // The token's expiration is cryptographically secured and cannot be tampered with - // The file's Expires property can be manually edited and should NOT be trusted for display - if (claimsPrincipal != null) - { - Expiration = claimsPrincipal.GetValue(UserLicenseConstants.Expires); - } - else - { - // No token - use the license file expiration (for older licenses without tokens) - Expiration = License.Expires; - } - - CustomerDiscount = ShouldIncludeDiscount(subscription?.CustomerDiscount) - ? new BillingCustomerDiscount(subscription!.CustomerDiscount!) - : null; - } - - public SubscriptionResponseModel(User user, UserLicense? license = null) + public SubscriptionResponseModel(User user) : base("subscription") { StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; - StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB + StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; MaxStorageGb = user.MaxStorageGb; Expiration = user.PremiumExpirationDate; - - if (license != null) - { - License = license; - } } 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; } - /// - /// Customer discount information from Stripe for the Milestone 2 subscription discount. - /// Only includes the specific Milestone 2 coupon when it's active. - /// This is for display purposes only and does not affect Stripe's automatic discount application. - /// - /// Null when: - /// - There is no active discount - /// - The discount coupon ID doesn't match the Milestone 2 coupon - /// - 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. - /// - /// The customer discount from subscription info, if any. - /// True if the discount should be included; false otherwise. - private static bool ShouldIncludeDiscount( - SubscriptionInfo.BillingCustomerDiscount? customerDiscount) - { - return customerDiscount != null && - customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount && - customerDiscount.Active; - } -} - -/// -/// Customer discount information from Stripe billing. -/// -public class BillingCustomerDiscount -{ - /// - /// 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 -{ - public BillingSubscription(SubscriptionInfo.BillingSubscription sub) - { - Status = sub.Status; - TrialStartDate = sub.TrialStartDate; - TrialEndDate = sub.TrialEndDate; - PeriodStartDate = sub.PeriodStartDate; - PeriodEndDate = sub.PeriodEndDate; - CancelledDate = sub.CancelledDate; - CancelAtEndDate = sub.CancelAtEndDate; - Cancelled = sub.Cancelled; - if (sub.Items != null) - { - Items = sub.Items.Select(i => new BillingSubscriptionItem(i)); - } - CollectionMethod = sub.CollectionMethod; - SuspensionDate = sub.SuspensionDate; - UnpaidPeriodEndDate = sub.UnpaidPeriodEndDate; - GracePeriod = sub.GracePeriod; - } - - public DateTime? TrialStartDate { get; set; } - public DateTime? TrialEndDate { get; set; } - public DateTime? PeriodStartDate { get; set; } - public DateTime? PeriodEndDate { get; set; } - public DateTime? CancelledDate { get; set; } - public bool CancelAtEndDate { 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 DateTime? SuspensionDate { get; set; } - public DateTime? UnpaidPeriodEndDate { get; set; } - public int? GracePeriod { get; set; } - - public class BillingSubscriptionItem - { - public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubscriptionItem item) - { - ProductId = item.ProductId; - Name = item.Name; - Amount = item.Amount; - Interval = item.Interval; - Quantity = item.Quantity; - SponsoredSubscriptionItem = item.SponsoredSubscriptionItem; - AddonSubscriptionItem = item.AddonSubscriptionItem; - } - - 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 bool SponsoredSubscriptionItem { get; set; } - public bool AddonSubscriptionItem { get; set; } - } -} - -public class BillingSubscriptionUpcomingInvoice -{ - public BillingSubscriptionUpcomingInvoice(SubscriptionInfo.BillingUpcomingInvoice inv) - { - Amount = inv.Amount; - Date = inv.Date; - } - - public decimal? Amount { get; set; } - public DateTime? Date { get; set; } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 4899b95c97a8..e316157cf02d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -204,9 +204,6 @@ public static class FeatureFlagKeys public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; - - - public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page"; public const string PM29108_EnablePersonalDiscounts = "pm-29108-enable-personal-discounts"; public const string PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade"; public const string PM32581_UseUpdateOrganizationSubscriptionCommand = "pm-32581-use-update-organization-subscription-command"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index c021fa2668e2..f083df7a3c14 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -42,9 +42,7 @@ Task ChangeEmailAsync(User user, string masterPassword, string n Task DeleteAsync(User user, string token); Task SendDeleteConfirmationAsync(string email); Task UpdateLicenseAsync(User user, UserLicense license); - Task AdjustStorageAsync(User user, short storageAdjustmentGb); Task CancelPremiumAsync(User user, bool? endOfPeriod = null); - Task ReinstatePremiumAsync(User user); Task EnablePremiumAsync(Guid userId, DateTime? expirationDate); Task DisablePremiumAsync(Guid userId, DateTime? expirationDate); Task UpdatePremiumExpirationAsync(Guid userId, DateTime? expirationDate); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index c1a4b078379e..c375135ad09d 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -803,33 +803,6 @@ public async Task UpdateLicenseAsync(User user, UserLicense license) await SaveUserAsync(user); } - // TODO: Remove with deletion of pm-29594-update-individual-subscription-page - public async Task AdjustStorageAsync(User user, short storageAdjustmentGb) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (!user.Premium) - { - throw new BadRequestException("Not a premium user."); - } - - var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); - - var baseStorageGb = (short)premiumPlan.Storage.Provided; - var secret = await BillingHelpers.AdjustStorageAsync( - _paymentService, - null, - _featureService, - user, - storageAdjustmentGb, - premiumPlan.Storage.StripePriceId, - baseStorageGb); - await SaveUserAsync(user); - return secret; - } //TODO: Remove with the deletion of PM32645_DeferPriceMigrationToRenewal feature flag public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null) { @@ -842,12 +815,6 @@ public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null) await _paymentService.CancelSubscriptionAsync(user, eop); } - // TODO: Remove with deletion of pm-29594-update-individual-subscription-page - public async Task ReinstatePremiumAsync(User user) - { - await _paymentService.ReinstateSubscriptionAsync(user); - } - public async Task EnablePremiumAsync(Guid userId, DateTime? expirationDate) { var user = await _userRepository.GetByIdAsync(userId); diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs deleted file mode 100644 index 7fb57526557b..000000000000 --- a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs +++ /dev/null @@ -1,778 +0,0 @@ -using System.Security.Claims; -using Bit.Api.Billing.Controllers; -using Bit.Core; -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Models.Business; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Subscriptions.Commands; -using Bit.Core.Entities; -using Bit.Core.Enums; -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 OneOf.Types; -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 IStripePaymentService _paymentService; - private readonly ILicensingService _licensingService; - private readonly GlobalSettings _globalSettings; - private readonly AccountsController _sut; - - public AccountsControllerTests() - { - _userService = Substitute.For(); - _paymentService = Substitute.For(); - _licensingService = Substitute.For(); - _globalSettings = new GlobalSettings { SelfHosted = false }; - - _sut = new AccountsController( - _userService, - Substitute.For(), - _licensingService, - Substitute.For() - ); - } - - public void Dispose() - { - _sut?.Dispose(); - } - - [Theory] - [BitAutoData] - public async Task GetSubscriptionAsync_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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - - 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_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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - - 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); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - - // 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - - 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - - 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_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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - 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 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: - // - 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - 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); - _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); - _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - 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_NeverIncludesDiscount(User user) - { - // Arrange - Self-hosted user (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); - - // Act - var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); - - // Assert - Should never include discount for self-hosted - Assert.NotNull(result); - Assert.Null(result.CustomerDiscount); - await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task GetSubscriptionAsync_NullGateway_NeverIncludesDiscount( - User user, - UserLicense license) - { - // Arrange - User with null gateway (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); - _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); - - // Act - var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); - - // Assert - Should never include discount when no gateway - Assert.NotNull(result); - Assert.Null(result.CustomerDiscount); - await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task PostReinstateAsync_WhenFlagEnabled_CallsReinstateCommand(User user) - { - // Arrange - var reinstateCommand = Substitute.For(); - var featureService = Substitute.For(); - var userService = Substitute.For(); - - featureService - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(true); - - userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - - reinstateCommand - .Run(user) - .Returns(new BillingCommandResult(new None())); - - var sut = new AccountsController(userService, featureService, Substitute.For(), reinstateCommand); - sut.ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() } - }; - - // Act - await sut.PostReinstateAsync(); - - // Assert - await reinstateCommand.Received(1).Run(user); - await userService.DidNotReceiveWithAnyArgs().ReinstatePremiumAsync(default); - } - - [Theory] - [BitAutoData] - public async Task PostReinstateAsync_WhenFlagDisabled_CallsLegacyUserService(User user) - { - // Arrange - var reinstateCommand = Substitute.For(); - var featureService = Substitute.For(); - var userService = Substitute.For(); - - featureService - .IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal) - .Returns(false); - - userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - - var sut = new AccountsController(userService, featureService, Substitute.For(), reinstateCommand); - sut.ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() } - }; - - // Act - await sut.PostReinstateAsync(); - - // Assert - await userService.Received(1).ReinstatePremiumAsync(user); - await reinstateCommand.DidNotReceiveWithAnyArgs().Run(default); - } -} diff --git a/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs deleted file mode 100644 index 6c5c7b504678..000000000000 --- a/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs +++ /dev/null @@ -1,350 +0,0 @@ -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); - - // 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); - - // Assert - 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); - - // 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); - - // 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_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); - - // 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); - - // 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); - - // 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); - - // 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); - - // 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/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 1a1425d3dc1f..252ff5842a00 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -14,7 +14,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Premium.Queries; -using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -574,50 +573,6 @@ public async Task AdminResetPasswordAsync_EmptyOrWhitespaceResetPasswordKey_Thro Assert.Equal("Organization User not valid", exception.Message); } - [Theory, BitAutoData] - public async Task AdjustStorageAsync_NullUser_ThrowsArgumentNullException( - SutProvider sutProvider) - { - await Assert.ThrowsAsync( - () => sutProvider.Sut.AdjustStorageAsync(null, 1)); - } - - [Theory, BitAutoData] - public async Task AdjustStorageAsync_NotPremium_ThrowsBadRequestException( - User user, SutProvider sutProvider) - { - user.Premium = false; - - await Assert.ThrowsAsync( - () => sutProvider.Sut.AdjustStorageAsync(user, 1)); - } - - [Theory, BitAutoData] - public async Task AdjustStorageAsync_Success_CallsPaymentServiceAndSavesUser( - User user, SutProvider sutProvider) - { - user.Premium = true; - user.GatewayCustomerId = "cus_123"; - user.GatewaySubscriptionId = "sub_123"; - user.MaxStorageGb = 1; - user.Storage = 0; - - var premiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan - { - Name = "Premium", - Available = true, - Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "premium-seat", Price = 10, Provided = 1 }, - Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "storage-gb-annually", Price = 4, Provided = 1 } - }; - - sutProvider.GetDependency().GetAvailablePremiumPlan().Returns(premiumPlan); - - await sutProvider.Sut.AdjustStorageAsync(user, 1); - - await sutProvider.GetDependency().Received(1) - .AdjustStorageAsync(user, Arg.Any(), premiumPlan.Storage.StripePriceId); - } - [Theory, BitAutoData] public async Task CancelPremiumAsync_CallsPaymentService( User user,