diff --git a/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs b/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs index 379c01d5e6..1f3261fbe6 100644 --- a/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs +++ b/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs @@ -19,6 +19,7 @@ public class SubscriptionDiscountsController( ILogger logger) : Controller { private const string SuccessKey = "Success"; + private const string ErrorKey = "Error"; [HttpGet] [RequirePermission(Permission.Tools_CreateEditTransaction)] @@ -205,5 +206,100 @@ public class SubscriptionDiscountsController( } } + [HttpGet("{id}")] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public async Task Edit(Guid id) + { + var discount = await subscriptionDiscountRepository.GetByIdAsync(id); + if (discount == null) + { + return NotFound(); + } + + var model = new EditSubscriptionDiscountModel(discount); + + if (model.StripeProductIds is { Count: > 0 }) + { + try + { + var products = await stripeAdapter.ListProductsAsync(new ProductListOptions + { + Ids = model.StripeProductIds.ToList() + }); + model.AppliesToProducts = products.ToDictionary(p => p.Id, p => p.Name); + } + catch (StripeException ex) + { + logger.LogError(ex, "Failed to fetch the coupon's associated products from Stripe. Coupon ID: {CouponId}", model.StripeCouponId); + ModelState.AddModelError(string.Empty, "Failed to fetch the coupon's associated products from Stripe. However, editing is still possible."); + } + } + + return View(model); + } + + [HttpPost("{id}")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public async Task Edit(Guid id, EditSubscriptionDiscountModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var discount = await subscriptionDiscountRepository.GetByIdAsync(id); + if (discount == null) + { + return NotFound(); + } + + try + { + discount.StartDate = model.StartDate; + discount.EndDate = model.EndDate; + discount.AudienceType = model.AudienceType; + discount.RevisionDate = DateTime.UtcNow; + + await subscriptionDiscountRepository.ReplaceAsync(discount); + + PersistSuccessMessage($"Discount '{discount.StripeCouponId}' updated successfully."); + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error updating subscription discount. Coupon ID: {CouponId}", discount.StripeCouponId); + ModelState.AddModelError(string.Empty, "An error occurred while updating the discount."); + return View(model); + } + } + + [HttpPost("{id}/delete")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public async Task Delete(Guid id) + { + var discount = await subscriptionDiscountRepository.GetByIdAsync(id); + if (discount == null) + { + return NotFound(); + } + + try + { + await subscriptionDiscountRepository.DeleteAsync(discount); + + PersistSuccessMessage($"Discount '{discount.StripeCouponId}' deleted successfully."); + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error deleting subscription discount. Coupon ID: {CouponId}", discount.StripeCouponId); + PersistErrorMessage("An error occurred while attempting to delete the discount."); + return RedirectToAction(nameof(Edit), new { id }); + } + } + private void PersistSuccessMessage(string message) => TempData[SuccessKey] = message; + private void PersistErrorMessage(string message) => TempData[ErrorKey] = message; } diff --git a/src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs b/src/Admin/Billing/Models/SubscriptionDiscount/CreateSubscriptionDiscountModel.cs similarity index 100% rename from src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs rename to src/Admin/Billing/Models/SubscriptionDiscount/CreateSubscriptionDiscountModel.cs diff --git a/src/Admin/Billing/Models/SubscriptionDiscount/EditSubscriptionDiscountModel.cs b/src/Admin/Billing/Models/SubscriptionDiscount/EditSubscriptionDiscountModel.cs new file mode 100644 index 0000000000..c3f942d5e8 --- /dev/null +++ b/src/Admin/Billing/Models/SubscriptionDiscount/EditSubscriptionDiscountModel.cs @@ -0,0 +1,63 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Subscriptions.Entities; + +namespace Bit.Admin.Billing.Models; + +public class EditSubscriptionDiscountModel : IValidatableObject +{ + public Guid Id { get; set; } + + public string StripeCouponId { get; set; } = null!; + public string? Name { get; set; } + public decimal? PercentOff { get; set; } + public long? AmountOff { get; set; } + public string? Currency { get; set; } + public string Duration { get; set; } = string.Empty; + public int? DurationInMonths { get; set; } + public ICollection? StripeProductIds { get; set; } + public Dictionary? AppliesToProducts { get; set; } // Key: ProductId, Value: ProductName + + [Required] + [Display(Name = "Start Date")] + public DateTime StartDate { get; set; } + + [Required] + [Display(Name = "End Date")] + public DateTime EndDate { get; set; } + + [Display(Name = "Restrict to users with no previous subscriptions?")] + public bool RestrictToNewUsersOnly { get; set; } + + public DiscountAudienceType AudienceType => RestrictToNewUsersOnly + ? DiscountAudienceType.UserHasNoPreviousSubscriptions + : DiscountAudienceType.AllUsers; + + public EditSubscriptionDiscountModel() { } + + public EditSubscriptionDiscountModel(SubscriptionDiscount discount) + { + Id = discount.Id; + StripeCouponId = discount.StripeCouponId; + Name = discount.Name; + PercentOff = discount.PercentOff; + AmountOff = discount.AmountOff; + Currency = discount.Currency; + Duration = discount.Duration; + DurationInMonths = discount.DurationInMonths; + StripeProductIds = discount.StripeProductIds; + StartDate = discount.StartDate; + EndDate = discount.EndDate; + RestrictToNewUsersOnly = discount.AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (EndDate < StartDate) + { + yield return new ValidationResult( + "End Date must be on or after Start Date.", + new[] { nameof(EndDate) }); + } + } +} diff --git a/src/Admin/Billing/Models/SubscriptionDiscountPagedModel.cs b/src/Admin/Billing/Models/SubscriptionDiscount/SubscriptionDiscountPagedModel.cs similarity index 100% rename from src/Admin/Billing/Models/SubscriptionDiscountPagedModel.cs rename to src/Admin/Billing/Models/SubscriptionDiscount/SubscriptionDiscountPagedModel.cs diff --git a/src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs b/src/Admin/Billing/Models/SubscriptionDiscount/SubscriptionDiscountViewModel.cs similarity index 100% rename from src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs rename to src/Admin/Billing/Models/SubscriptionDiscount/SubscriptionDiscountViewModel.cs diff --git a/src/Admin/Billing/Views/SubscriptionDiscounts/Edit.cshtml b/src/Admin/Billing/Views/SubscriptionDiscounts/Edit.cshtml new file mode 100644 index 0000000000..dbfa020952 --- /dev/null +++ b/src/Admin/Billing/Views/SubscriptionDiscounts/Edit.cshtml @@ -0,0 +1,133 @@ +@model EditSubscriptionDiscountModel +@{ + ViewData["Title"] = "Edit Discount"; +} + +

Edit Discount

+ +
+ +
+ + + + + + + + @if (Model.AppliesToProducts != null) + { + var index = 0; + @foreach (var product in Model.AppliesToProducts) + { + + + index++; + } + } +
+
+
Stripe Coupon Details
+
+
+
+
Coupon ID:
+
@Model.StripeCouponId
+ + @if (!string.IsNullOrEmpty(Model.Name)) + { +
Name:
+
@Model.Name
+ } + +
Discount:
+
+ @if (Model.PercentOff.HasValue) + { + @Model.PercentOff% off + } + else if (Model.AmountOff.HasValue) + { + $@(Model.AmountOff / 100m) off + @if (!string.IsNullOrEmpty(Model.Currency)) + { + (@Model.Currency.ToUpper()) + } + } +
+ +
Duration:
+
+ @Model.Duration + @if (Model.DurationInMonths.HasValue) + { + (@Model.DurationInMonths months) + } +
+ + @if (Model.AppliesToProducts != null && Model.AppliesToProducts.Count != 0) + { +
Applies To Products:
+
+
    + @foreach (var product in Model.AppliesToProducts) + { +
  • @product.Value
  • + } +
+
+ } + else if (Model.StripeProductIds != null && Model.StripeProductIds.Count != 0) + { +
Applies To Products:
+
+
    + @foreach (var productId in Model.StripeProductIds) + { +
  • @productId
  • + } +
+
+ } +
+
+
+ +
+
+
Bitwarden Configuration
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+ + Cancel +
+
+ +
+
+
diff --git a/test/Admin.Test/Billing/Controllers/SubscriptionDiscountsControllerTests.cs b/test/Admin.Test/Billing/Controllers/SubscriptionDiscountsControllerTests.cs index f6fa71a3c7..bbba712de0 100644 --- a/test/Admin.Test/Billing/Controllers/SubscriptionDiscountsControllerTests.cs +++ b/test/Admin.Test/Billing/Controllers/SubscriptionDiscountsControllerTests.cs @@ -456,4 +456,276 @@ public class SubscriptionDiscountsControllerTests Assert.False(sutProvider.Sut.ModelState.IsValid); Assert.Contains("error occurred", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage); } + + [Theory, BitAutoData] + public async Task Edit_Get_ReturnsViewWithModel( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + sutProvider.GetDependency() + .ListProductsAsync(Arg.Any()) + .Returns(new List()); + + var result = await sutProvider.Sut.Edit(discount.Id); + + Assert.IsType(result); + var viewResult = (ViewResult)result; + var model = Assert.IsType(viewResult.Model); + Assert.Equal(discount.Id, model.Id); + Assert.Equal(discount.StripeCouponId, model.StripeCouponId); + Assert.Equal(discount.StartDate, model.StartDate); + Assert.Equal(discount.EndDate, model.EndDate); + } + + [Theory, BitAutoData] + public async Task Edit_Get_WithStripeProducts_PopulatesAppliesToProducts( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + discount.StripeProductIds = new List { "prod_1", "prod_2" }; + var stripeProducts = new List + { + new() { Id = "prod_1", Name = "Product One" }, + new() { Id = "prod_2", Name = "Product Two" } + }; + + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + sutProvider.GetDependency() + .ListProductsAsync(Arg.Any()) + .Returns(stripeProducts); + + var result = await sutProvider.Sut.Edit(discount.Id); + + var viewResult = Assert.IsType(result); + var model = Assert.IsType(viewResult.Model); + Assert.NotNull(model.AppliesToProducts); + Assert.Equal(2, model.AppliesToProducts.Count); + Assert.Equal("Product One", model.AppliesToProducts["prod_1"]); + Assert.Equal("Product Two", model.AppliesToProducts["prod_2"]); + } + + [Theory, BitAutoData] + public async Task Edit_Get_WhenStripeProductLookupFails_StillReturnsView( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + discount.StripeProductIds = new List { "prod_1" }; + + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + sutProvider.GetDependency() + .ListProductsAsync(Arg.Any()) + .Throws(new StripeException()); + + var result = await sutProvider.Sut.Edit(discount.Id); + + var viewResult = Assert.IsType(result); + var model = Assert.IsType(viewResult.Model); + Assert.Null(model.AppliesToProducts); + Assert.False(sutProvider.Sut.ModelState.IsValid); + Assert.Contains("Failed to fetch", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage); + } + + [Theory, BitAutoData] + public async Task Edit_Get_WhenNotFound_ReturnsNotFound( + Guid id, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(id) + .Returns((SubscriptionDiscount?)null); + + var result = await sutProvider.Sut.Edit(id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Edit_Post_ValidModel_UpdatesBitwardenFieldsAndRedirects( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + var model = new EditSubscriptionDiscountModel + { + StartDate = DateTime.UtcNow.Date.AddDays(1), + EndDate = DateTime.UtcNow.Date.AddMonths(2), + RestrictToNewUsersOnly = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + sutProvider.Sut.TempData = tempData; + + var result = await sutProvider.Sut.Edit(discount.Id, model); + + Assert.IsType(result); + var redirectResult = (RedirectToActionResult)result; + Assert.Equal(nameof(SubscriptionDiscountsController.Index), redirectResult.ActionName); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(d => + d.StartDate == model.StartDate && + d.EndDate == model.EndDate && + d.AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions)); + } + + [Theory, BitAutoData] + public async Task Edit_Post_ValidModel_DoesNotUpdateStripeFields( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + var originalStripeCouponId = discount.StripeCouponId; + var originalPercentOff = discount.PercentOff; + var originalAmountOff = discount.AmountOff; + var originalDuration = discount.Duration; + + var model = new EditSubscriptionDiscountModel + { + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date.AddMonths(1), + RestrictToNewUsersOnly = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + sutProvider.Sut.TempData = tempData; + + await sutProvider.Sut.Edit(discount.Id, model); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(d => + d.StripeCouponId == originalStripeCouponId && + d.PercentOff == originalPercentOff && + d.AmountOff == originalAmountOff && + d.Duration == originalDuration)); + } + + [Theory, BitAutoData] + public async Task Edit_Post_InvalidModelState_ReturnsView( + Guid id, + EditSubscriptionDiscountModel model, + SutProvider sutProvider) + { + sutProvider.Sut.ModelState.AddModelError(nameof(model.EndDate), "End Date must be on or after Start Date."); + + var result = await sutProvider.Sut.Edit(id, model); + + Assert.IsType(result); + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Edit_Post_RepositoryThrowsException_ReturnsViewWithError( + SubscriptionDiscount discount, + EditSubscriptionDiscountModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + sutProvider.GetDependency() + .ReplaceAsync(Arg.Any()) + .Throws(new Exception("Database error")); + + var result = await sutProvider.Sut.Edit(discount.Id, model); + + Assert.IsType(result); + Assert.False(sutProvider.Sut.ModelState.IsValid); + Assert.Contains("error occurred", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage); + } + + [Theory, BitAutoData] + public async Task Edit_Post_WhenNotFound_ReturnsNotFound( + Guid id, + EditSubscriptionDiscountModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(id) + .Returns((SubscriptionDiscount?)null); + + var result = await sutProvider.Sut.Edit(id, model); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Delete_Post_DeletesDiscountAndRedirectsToIndex( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + sutProvider.Sut.TempData = tempData; + + var result = await sutProvider.Sut.Delete(discount.Id); + + Assert.IsType(result); + var redirectResult = (RedirectToActionResult)result; + Assert.Equal(nameof(SubscriptionDiscountsController.Index), redirectResult.ActionName); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(discount); + } + + [Theory, BitAutoData] + public async Task Delete_Post_RepositoryThrowsException_RedirectsToEditWithError( + SubscriptionDiscount discount, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(discount.Id) + .Returns(discount); + + sutProvider.GetDependency() + .DeleteAsync(discount) + .Throws(new Exception("Database error")); + + var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + sutProvider.Sut.TempData = tempData; + + var result = await sutProvider.Sut.Delete(discount.Id); + + var redirectResult = Assert.IsType(result); + Assert.Equal(nameof(SubscriptionDiscountsController.Edit), redirectResult.ActionName); + Assert.Contains("attempting to delete", sutProvider.Sut.TempData["Error"]!.ToString()); + } + + [Theory, BitAutoData] + public async Task Delete_Post_WhenNotFound_ReturnsNotFound( + Guid id, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(id) + .Returns((SubscriptionDiscount?)null); + + var result = await sutProvider.Sut.Delete(id); + + Assert.IsType(result); + } } diff --git a/test/Admin.Test/Billing/Models/EditSubscriptionDiscountModelTests.cs b/test/Admin.Test/Billing/Models/EditSubscriptionDiscountModelTests.cs new file mode 100644 index 0000000000..ca9c74ab79 --- /dev/null +++ b/test/Admin.Test/Billing/Models/EditSubscriptionDiscountModelTests.cs @@ -0,0 +1,145 @@ +using Bit.Admin.Billing.Models; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Subscriptions.Entities; + +namespace Admin.Test.Billing.Models; + +public class EditSubscriptionDiscountModelTests +{ + [Fact] + public void AudienceType_WhenRestrictToNewUsersOnly_ReturnsUserHasNoPreviousSubscriptions() + { + var model = new EditSubscriptionDiscountModel + { + RestrictToNewUsersOnly = true + }; + + Assert.Equal(DiscountAudienceType.UserHasNoPreviousSubscriptions, model.AudienceType); + } + + [Fact] + public void AudienceType_WhenNotRestricted_ReturnsAllUsers() + { + var model = new EditSubscriptionDiscountModel + { + RestrictToNewUsersOnly = false + }; + + Assert.Equal(DiscountAudienceType.AllUsers, model.AudienceType); + } + + [Fact] + public void Validate_WhenEndDateBeforeStartDate_ReturnsError() + { + var model = new EditSubscriptionDiscountModel + { + StartDate = DateTime.UtcNow.Date.AddDays(10), + EndDate = DateTime.UtcNow.Date + }; + + var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(model); + var results = model.Validate(validationContext).ToList(); + + Assert.Single(results); + Assert.Contains("End Date must be on or after Start Date", results[0].ErrorMessage); + Assert.Contains(nameof(model.EndDate), results[0].MemberNames); + } + + [Fact] + public void Validate_WhenEndDateEqualsStartDate_NoError() + { + var model = new EditSubscriptionDiscountModel + { + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date + }; + + var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(model); + var results = model.Validate(validationContext).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Validate_WhenEndDateAfterStartDate_NoError() + { + var model = new EditSubscriptionDiscountModel + { + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date.AddDays(10) + }; + + var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(model); + var results = model.Validate(validationContext).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Constructor_FromEntity_MapsAllProperties() + { + var discount = new SubscriptionDiscount + { + Id = Guid.NewGuid(), + StripeCouponId = "COUPON123", + Name = "Test Coupon", + PercentOff = 25m, + AmountOff = null, + Currency = "usd", + Duration = "once", + DurationInMonths = null, + StripeProductIds = new List { "prod_1", "prod_2" }, + StartDate = new DateTime(2025, 1, 1), + EndDate = new DateTime(2025, 12, 31), + AudienceType = DiscountAudienceType.AllUsers + }; + + var model = new EditSubscriptionDiscountModel(discount); + + Assert.Equal(discount.Id, model.Id); + Assert.Equal(discount.StripeCouponId, model.StripeCouponId); + Assert.Equal(discount.Name, model.Name); + Assert.Equal(discount.PercentOff, model.PercentOff); + Assert.Equal(discount.AmountOff, model.AmountOff); + Assert.Equal(discount.Currency, model.Currency); + Assert.Equal(discount.Duration, model.Duration); + Assert.Equal(discount.DurationInMonths, model.DurationInMonths); + Assert.Equal(discount.StripeProductIds, model.StripeProductIds); + Assert.Equal(discount.StartDate, model.StartDate); + Assert.Equal(discount.EndDate, model.EndDate); + } + + [Fact] + public void Constructor_FromEntity_WhenAudienceTypeIsUserHasNoPreviousSubscriptions_SetsRestrictToNewUsersOnlyTrue() + { + var discount = new SubscriptionDiscount + { + StripeCouponId = "COUPON123", + Duration = "once", + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date.AddMonths(1), + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions + }; + + var model = new EditSubscriptionDiscountModel(discount); + + Assert.True(model.RestrictToNewUsersOnly); + } + + [Fact] + public void Constructor_FromEntity_WhenAudienceTypeIsAllUsers_SetsRestrictToNewUsersOnlyFalse() + { + var discount = new SubscriptionDiscount + { + StripeCouponId = "COUPON123", + Duration = "once", + StartDate = DateTime.UtcNow.Date, + EndDate = DateTime.UtcNow.Date.AddMonths(1), + AudienceType = DiscountAudienceType.AllUsers + }; + + var model = new EditSubscriptionDiscountModel(discount); + + Assert.False(model.RestrictToNewUsersOnly); + } +}