diff --git a/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs b/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs new file mode 100644 index 0000000000..379c01d5e6 --- /dev/null +++ b/src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs @@ -0,0 +1,209 @@ +using Bit.Admin.Billing.Models; +using Bit.Admin.Enums; +using Bit.Admin.Utilities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Entities; +using Bit.Core.Billing.Subscriptions.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Stripe; + +namespace Bit.Admin.Billing.Controllers; + +[Authorize] +[Route("subscription-discounts")] +public class SubscriptionDiscountsController( + ISubscriptionDiscountRepository subscriptionDiscountRepository, + IStripeAdapter stripeAdapter, + ILogger logger) : Controller +{ + private const string SuccessKey = "Success"; + + [HttpGet] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public async Task Index(int page = 1, int count = 25) + { + if (page < 1) + { + page = 1; + } + + if (count < 1) + { + count = 1; + } + + var skip = (page - 1) * count; + var discounts = await subscriptionDiscountRepository.ListAsync(skip, count); + + var discountViewModels = discounts.Select(d => new SubscriptionDiscountViewModel + { + Id = d.Id, + StripeCouponId = d.StripeCouponId, + Name = d.Name, + PercentOff = d.PercentOff, + AmountOff = d.AmountOff, + Currency = d.Currency, + Duration = d.Duration, + DurationInMonths = d.DurationInMonths, + StartDate = d.StartDate, + EndDate = d.EndDate, + AudienceType = d.AudienceType, + CreationDate = d.CreationDate + }).ToList(); + + var model = new SubscriptionDiscountPagedModel + { + Items = discountViewModels, + Page = page, + Count = count + }; + + return View(model); + } + + [HttpGet("create")] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public IActionResult Create() + { + return View(new CreateSubscriptionDiscountModel()); + } + + [HttpPost("import-coupon")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public async Task ImportCoupon(CreateSubscriptionDiscountModel model) + { + if (!ModelState.IsValid) + { + return View("Create", model); + } + + try + { + var existing = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(model.StripeCouponId); + if (existing != null) + { + ModelState.AddModelError(nameof(model.StripeCouponId), + "This coupon has already been imported."); + return View("Create", model); + } + + Coupon coupon; + try + { + var options = new CouponGetOptions(); + options.AddExpand(StripeConstants.CouponExpandablePropertyNames.AppliesTo); + coupon = await stripeAdapter.GetCouponAsync(model.StripeCouponId, options); + } + catch (StripeException ex) + { + var errorMessage = ex.StripeError?.Code == "resource_missing" + ? "Coupon not found in Stripe. Please verify the coupon ID." + : "An error occurred while fetching the coupon from Stripe."; + + logger.LogError(ex, "Stripe coupon error: {CouponId}", model.StripeCouponId); + ModelState.AddModelError(nameof(model.StripeCouponId), errorMessage); + return View("Create", model); + } + + model.Name = coupon.Name; + model.PercentOff = coupon.PercentOff; + model.AmountOff = coupon.AmountOff; + model.Currency = coupon.Currency; + model.Duration = coupon.Duration; + model.DurationInMonths = (int?)coupon.DurationInMonths; + + var productIds = coupon.AppliesTo?.Products; + if (productIds != null && productIds.Count != 0) + { + try + { + var allProducts = await stripeAdapter.ListProductsAsync(new ProductListOptions + { + Ids = productIds.ToList() + }); + + model.AppliesToProducts = allProducts + .ToDictionary(product => product.Id, product => product.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."); + return View("Create", model); + } + } + + model.IsImported = true; + return View("Create", model); + } + catch (Exception ex) + { + logger.LogError(ex, "Error importing coupon from Stripe. Coupon ID: {CouponId}", model.StripeCouponId); + ModelState.AddModelError(string.Empty, "An error occurred while importing the coupon."); + return View("Create", model); + } + } + + [HttpPost("create")] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Tools_CreateEditTransaction)] + public async Task Create(CreateSubscriptionDiscountModel model) + { + if (!model.IsImported) + { + ModelState.AddModelError(string.Empty, + "Please import the coupon from Stripe before submitting."); + return View(model); + } + + if (!ModelState.IsValid) + { + return View(model); + } + + try + { + // Check for duplicate coupon to prevent race condition + var existing = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(model.StripeCouponId); + if (existing != null) + { + ModelState.AddModelError(nameof(model.StripeCouponId), + "This coupon has already been imported."); + return View(model); + } + + var discount = new SubscriptionDiscount + { + StripeCouponId = model.StripeCouponId, + Name = model.Name, + PercentOff = model.PercentOff, + AmountOff = model.AmountOff, + Currency = model.Currency, + Duration = model.Duration, + DurationInMonths = model.DurationInMonths, + StripeProductIds = model.AppliesToProducts?.Keys.ToList(), + StartDate = model.StartDate, + EndDate = model.EndDate, + AudienceType = model.AudienceType, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + + await subscriptionDiscountRepository.CreateAsync(discount); + + PersistSuccessMessage($"Discount '{model.StripeCouponId}' imported successfully."); + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating subscription discount. Coupon ID: {CouponId}", model.StripeCouponId); + ModelState.AddModelError(string.Empty, "An error occurred while saving the discount."); + return View(model); + } + } + + private void PersistSuccessMessage(string message) => TempData[SuccessKey] = message; +} diff --git a/src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs b/src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs new file mode 100644 index 0000000000..82859e044b --- /dev/null +++ b/src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Admin.Billing.Models; + +public class CreateSubscriptionDiscountModel : IValidatableObject +{ + [Required] + [Display(Name = "Stripe Coupon ID")] + [MaxLength(50)] + 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 Dictionary? AppliesToProducts { get; set; } // Key: ProductId, Value: ProductName + + [Required] + [Display(Name = "Start Date")] + public DateTime StartDate { get; set; } = DateTime.UtcNow.Date; + + [Required] + [Display(Name = "End Date")] + public DateTime EndDate { get; set; } = DateTime.UtcNow.Date.AddMonths(1); + + [Display(Name = "Restrict to users with no previous subscriptions?")] + public bool RestrictToNewUsersOnly { get; set; } + + public DiscountAudienceType AudienceType => RestrictToNewUsersOnly + ? DiscountAudienceType.UserHasNoPreviousSubscriptions + : DiscountAudienceType.AllUsers; + + public bool IsImported { get; set; } + + 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/SubscriptionDiscountPagedModel.cs new file mode 100644 index 0000000000..96dff6c6e8 --- /dev/null +++ b/src/Admin/Billing/Models/SubscriptionDiscountPagedModel.cs @@ -0,0 +1,7 @@ +using Bit.Admin.Models; + +namespace Bit.Admin.Billing.Models; + +public class SubscriptionDiscountPagedModel : PagedModel +{ +} diff --git a/src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs b/src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs new file mode 100644 index 0000000000..439d4439fe --- /dev/null +++ b/src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs @@ -0,0 +1,27 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Admin.Billing.Models; + +public class SubscriptionDiscountViewModel +{ + 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; } = null!; + public int? DurationInMonths { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public DiscountAudienceType AudienceType { get; set; } + public DateTime CreationDate { get; set; } + public bool IsActive => DateTime.UtcNow >= StartDate && DateTime.UtcNow <= EndDate; + + public string DiscountDisplay => PercentOff.HasValue + ? $"{PercentOff.Value:G29}% off" + : $"${AmountOff / 100m} off"; + + public bool IsRestrictedToNewUsersOnly => AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions; + public bool IsAvailableToAllUsers => AudienceType == DiscountAudienceType.AllUsers; +} diff --git a/src/Admin/Billing/Views/SubscriptionDiscounts/Create.cshtml b/src/Admin/Billing/Views/SubscriptionDiscounts/Create.cshtml new file mode 100644 index 0000000000..144e3a8ea7 --- /dev/null +++ b/src/Admin/Billing/Views/SubscriptionDiscounts/Create.cshtml @@ -0,0 +1,17 @@ +@model CreateSubscriptionDiscountModel +@{ + ViewData["Title"] = "Import Discount From Stripe"; +} + +

Import Discount From Stripe

+ +
+ +@if (!Model.IsImported) +{ + +} +else +{ + +} diff --git a/src/Admin/Billing/Views/SubscriptionDiscounts/Index.cshtml b/src/Admin/Billing/Views/SubscriptionDiscounts/Index.cshtml new file mode 100644 index 0000000000..4af9ee0711 --- /dev/null +++ b/src/Admin/Billing/Views/SubscriptionDiscounts/Index.cshtml @@ -0,0 +1,127 @@ +@model SubscriptionDiscountPagedModel +@{ + ViewData["Title"] = "Discounts"; +} + +
+
+
+

Discounts

+
+ +
+ + @if (Model.Items.Any()) + { +
+ + + + + + + + + + + + + + @foreach (var discount in Model.Items) + { + var dateRange = $"{discount.StartDate.ToString("MM/dd/yyyy")} - {discount.EndDate.ToString("MM/dd/yyyy")}"; + + + + + + + + + + } + +
Stripe Coupon IDNameDiscountDurationOnly New Users?StatusCreated
@discount.StripeCouponId + + @discount.Name + + @discount.DiscountDisplay + @discount.Duration + @if (discount.DurationInMonths.HasValue) + { + (@discount.DurationInMonths months) + } + + @if (discount.IsRestrictedToNewUsersOnly) + { + + } + + @if (discount.IsActive) + { + Active + } + else if (DateTime.UtcNow < discount.StartDate) + { + Scheduled + } + else + { + Expired + } + @discount.CreationDate.ToString("MM/dd/yyyy")
+
+ + + } + else + { +
+ No subscription discounts found. Import one from Stripe. +
+ } +
diff --git a/src/Admin/Billing/Views/SubscriptionDiscounts/Partials/_ConfigureDiscountForm.cshtml b/src/Admin/Billing/Views/SubscriptionDiscounts/Partials/_ConfigureDiscountForm.cshtml new file mode 100644 index 0000000000..6ee91e3eab --- /dev/null +++ b/src/Admin/Billing/Views/SubscriptionDiscounts/Partials/_ConfigureDiscountForm.cshtml @@ -0,0 +1,115 @@ +@model CreateSubscriptionDiscountModel + +
+ + + + + + + + + @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
  • + } +
+
+ } +
+
+
+ +
+
+
Step 2: Bitwarden Configuration
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + + Back + + Cancel +
+
diff --git a/src/Admin/Billing/Views/SubscriptionDiscounts/Partials/_ImportCouponForm.cshtml b/src/Admin/Billing/Views/SubscriptionDiscounts/Partials/_ImportCouponForm.cshtml new file mode 100644 index 0000000000..426d97287b --- /dev/null +++ b/src/Admin/Billing/Views/SubscriptionDiscounts/Partials/_ImportCouponForm.cshtml @@ -0,0 +1,25 @@ +@model CreateSubscriptionDiscountModel + +
+
+
+
Step 1: Import Stripe Coupon
+
+
+
+ + + + Enter the Stripe coupon ID and click Import to fetch the coupon details from Stripe. + +
+
+
+ +
+ + Cancel +
+
diff --git a/src/Admin/Billing/Views/_ViewImports.cshtml b/src/Admin/Billing/Views/_ViewImports.cshtml index 02423ba0e7..6d9aad7e00 100644 --- a/src/Admin/Billing/Views/_ViewImports.cshtml +++ b/src/Admin/Billing/Views/_ViewImports.cshtml @@ -1,5 +1,7 @@ @using Microsoft.AspNetCore.Identity @using Bit.Admin.AdminConsole @using Bit.Admin.AdminConsole.Models +@using Bit.Admin.Billing.Models +@using Bit.Core.Billing.Enums @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper "*, Admin" diff --git a/src/Admin/Views/Shared/_Layout.cshtml b/src/Admin/Views/Shared/_Layout.cshtml index c13be428b4..9810d061e5 100644 --- a/src/Admin/Views/Shared/_Layout.cshtml +++ b/src/Admin/Views/Shared/_Layout.cshtml @@ -1,8 +1,10 @@ @using Bit.Admin.Enums; +@using Bit.Core; @inject SignInManager SignInManager @inject Bit.Core.Settings.GlobalSettings GlobalSettings @inject Bit.Admin.Services.IAccessControlService AccessControlService +@inject Bit.Core.Services.IFeatureService FeatureService @{ var canViewUsers = AccessControlService.UserHasPermission(Permission.User_List_View); @@ -14,6 +16,7 @@ var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser); var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile); var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents); + var enablePersonalDiscounts = FeatureService.IsEnabled(FeatureFlagKeys.PM29108_EnablePersonalDiscounts); var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser || canGenerateLicense; @@ -63,6 +66,14 @@ Providers } + @if (canCreateTransaction && enablePersonalDiscounts) + { + + } @if (canViewTools) {