1
0
mirror of https://github.com/bitwarden/server synced 2026-02-25 08:53:21 +00:00

[PM-30109] edit discounts in bitwarden portal (#7032)

* [PM-30109] edit discounts in bitwarden portal

* forgot model error

* dotnet format

* pr feedback

* pr feedback
This commit is contained in:
Kyle Denney
2026-02-23 09:43:36 -06:00
committed by GitHub
parent 3dbd17f61d
commit b88ce58b59
8 changed files with 709 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ public class SubscriptionDiscountsController(
ILogger<SubscriptionDiscountsController> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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;
}

View File

@@ -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<string>? StripeProductIds { get; set; }
public Dictionary<string, string>? 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<ValidationResult> Validate(ValidationContext validationContext)
{
if (EndDate < StartDate)
{
yield return new ValidationResult(
"End Date must be on or after Start Date.",
new[] { nameof(EndDate) });
}
}
}

View File

@@ -0,0 +1,133 @@
@model EditSubscriptionDiscountModel
@{
ViewData["Title"] = "Edit Discount";
}
<h1>Edit Discount</h1>
<div asp-validation-summary="All" class="alert alert-danger"></div>
<form method="post" asp-action="Edit" asp-route-id="@Model.Id" id="edit-form">
<input type="hidden" asp-for="StripeCouponId" />
<input type="hidden" asp-for="Name" />
<input type="hidden" asp-for="PercentOff" />
<input type="hidden" asp-for="AmountOff" />
<input type="hidden" asp-for="Currency" />
<input type="hidden" asp-for="Duration" />
<input type="hidden" asp-for="DurationInMonths" />
@if (Model.AppliesToProducts != null)
{
var index = 0;
@foreach (var product in Model.AppliesToProducts)
{
<input type="hidden" name="AppliesToProducts[@index].Key" value="@product.Key" />
<input type="hidden" name="AppliesToProducts[@index].Value" value="@product.Value" />
index++;
}
}
<div class="card mb-3">
<div class="card-header">
<h5>Stripe Coupon Details</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Coupon ID:</dt>
<dd class="col-sm-9"><code>@Model.StripeCouponId</code></dd>
@if (!string.IsNullOrEmpty(Model.Name))
{
<dt class="col-sm-3">Name:</dt>
<dd class="col-sm-9">@Model.Name</dd>
}
<dt class="col-sm-3">Discount:</dt>
<dd class="col-sm-9">
@if (Model.PercentOff.HasValue)
{
<text>@Model.PercentOff% off</text>
}
else if (Model.AmountOff.HasValue)
{
<text>$@(Model.AmountOff / 100m) off</text>
@if (!string.IsNullOrEmpty(Model.Currency))
{
<text> (@Model.Currency.ToUpper())</text>
}
}
</dd>
<dt class="col-sm-3">Duration:</dt>
<dd class="col-sm-9">
@Model.Duration
@if (Model.DurationInMonths.HasValue)
{
<text>(@Model.DurationInMonths months)</text>
}
</dd>
@if (Model.AppliesToProducts != null && Model.AppliesToProducts.Count != 0)
{
<dt class="col-sm-3">Applies To Products:</dt>
<dd class="col-sm-9">
<ul class="mb-0">
@foreach (var product in Model.AppliesToProducts)
{
<li>@product.Value</li>
}
</ul>
</dd>
}
else if (Model.StripeProductIds != null && Model.StripeProductIds.Count != 0)
{
<dt class="col-sm-3">Applies To Products:</dt>
<dd class="col-sm-9">
<ul class="mb-0">
@foreach (var productId in Model.StripeProductIds)
{
<li><code>@productId</code></li>
}
</ul>
</dd>
}
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5>Bitwarden Configuration</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="StartDate" class="form-label"></label>
<input asp-for="StartDate" type="date" class="form-control" required />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label asp-for="EndDate" class="form-label"></label>
<input asp-for="EndDate" type="date" class="form-control" required />
</div>
</div>
</div>
<div class="mb-3 form-check">
<input asp-for="RestrictToNewUsersOnly" type="checkbox" class="form-check-input" />
<label asp-for="RestrictToNewUsersOnly" class="form-check-label"></label>
</div>
</div>
</div>
</form>
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<a asp-action="Index" class="btn btn-secondary ms-2">Cancel</a>
<div class="ms-auto d-flex">
<form method="post" asp-action="Delete" asp-route-id="@Model.Id"
onsubmit="return confirm('Are you sure you want to delete this discount?')">
<button class="btn btn-danger" type="submit">Delete Discount</button>
</form>
</div>
</div>