1
0
mirror of https://github.com/bitwarden/server synced 2026-02-21 03:43:44 +00:00

[PM-30108] import discount from stripe (#6982)

* [PM-30108] import discount from stripe

* fix repo tests

* pr feedback

* wrap discounts in feature flag

* claude pr feedback
This commit is contained in:
Kyle Denney
2026-02-17 12:57:14 -06:00
committed by GitHub
parent 3753a5e853
commit f0c69cedc2
24 changed files with 1521 additions and 1 deletions

View File

@@ -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<SubscriptionDiscountsController> logger) : Controller
{
private const string SuccessKey = "Success";
[HttpGet]
[RequirePermission(Permission.Tools_CreateEditTransaction)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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;
}

View File

@@ -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<string, string>? 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<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,7 @@
using Bit.Admin.Models;
namespace Bit.Admin.Billing.Models;
public class SubscriptionDiscountPagedModel : PagedModel<SubscriptionDiscountViewModel>
{
}

View File

@@ -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;
}

View File

@@ -0,0 +1,17 @@
@model CreateSubscriptionDiscountModel
@{
ViewData["Title"] = "Import Discount From Stripe";
}
<h1>Import Discount From Stripe</h1>
<div asp-validation-summary="All" class="alert alert-danger"></div>
@if (!Model.IsImported)
{
<partial name="Partials/_ImportCouponForm" model="Model" />
}
else
{
<partial name="Partials/_ConfigureDiscountForm" model="Model" />
}

View File

@@ -0,0 +1,127 @@
@model SubscriptionDiscountPagedModel
@{
ViewData["Title"] = "Discounts";
}
<div class="container-fluid">
<div class="row mb-3">
<div class="col">
<h1>Discounts</h1>
</div>
<div class="col-auto">
<a asp-action="Create" class="btn btn-primary">
<i class="fa fa-plus"></i> Import Discount
</a>
</div>
</div>
@if (Model.Items.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Stripe Coupon ID</th>
<th>Name</th>
<th>Discount</th>
<th>Duration</th>
<th>Only New Users?</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
@foreach (var discount in Model.Items)
{
var dateRange = $"{discount.StartDate.ToString("MM/dd/yyyy")} - {discount.EndDate.ToString("MM/dd/yyyy")}";
<tr>
<td><code>@discount.StripeCouponId</code></td>
<td>
<a asp-action="Edit" asp-route-id="@discount.Id">
@discount.Name
</a>
</td>
<td>@discount.DiscountDisplay</td>
<td>
@discount.Duration
@if (discount.DurationInMonths.HasValue)
{
<text>(@discount.DurationInMonths months)</text>
}
</td>
<td>
@if (discount.IsRestrictedToNewUsersOnly)
{
<i class="fa fa-check"></i>
}
</td>
<td>
@if (discount.IsActive)
{
<span class="badge bg-success" title="@dateRange">Active</span>
}
else if (DateTime.UtcNow < discount.StartDate)
{
<span class="badge bg-secondary" title="@dateRange">Scheduled</span>
}
else
{
<span class="badge bg-danger" title="@dateRange">Expired</span>
}
</td>
<td>@discount.CreationDate.ToString("MM/dd/yyyy")</td>
</tr>
}
</tbody>
</table>
</div>
<nav>
<ul class="pagination">
@if (Model.PreviousPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-action="Index"
asp-route-page="@Model.PreviousPage.Value"
asp-route-count="@Model.Count">
Previous
</a>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
<li class="page-item active">
<span class="page-link">Page @Model.Page</span>
</li>
@if (Model.NextPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-action="Index"
asp-route-page="@Model.NextPage.Value"
asp-route-count="@Model.Count">
Next
</a>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
</nav>
}
else
{
<div class="alert alert-info">
No subscription discounts found. <a asp-action="Create">Import one from Stripe</a>.
</div>
}
</div>

View File

@@ -0,0 +1,115 @@
@model CreateSubscriptionDiscountModel
<form method="post" asp-action="Create">
<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" />
<input type="hidden" asp-for="IsImported" />
@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>
}
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5>Step 2: 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>
<div class="mb-3">
<button type="submit" class="btn btn-success">
<i class="fa fa-save"></i> Save Discount
</button>
<a asp-action="Create" class="btn btn-secondary">
Back
</a>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</div>
</form>

View File

@@ -0,0 +1,25 @@
@model CreateSubscriptionDiscountModel
<form method="post" asp-action="ImportCoupon">
<div class="card mb-3">
<div class="card-header">
<h5>Step 1: Import Stripe Coupon</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="StripeCouponId" class="form-label"></label>
<input asp-for="StripeCouponId" class="form-control" autofocus />
<small class="form-text text-muted">
Enter the Stripe coupon ID and click Import to fetch the coupon details from Stripe.
</small>
</div>
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">
<i class="fa fa-download"></i> Import from Stripe
</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</div>
</form>

View File

@@ -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"

View File

@@ -1,8 +1,10 @@
@using Bit.Admin.Enums;
@using Bit.Core;
@inject SignInManager<IdentityUser> 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 @@
<a class="nav-link" asp-controller="Providers" asp-action="Index">Providers</a>
</li>
}
@if (canCreateTransaction && enablePersonalDiscounts)
{
<li class="nav-item" active-controller="SubscriptionDiscounts">
<a class="nav-link" asp-controller="SubscriptionDiscounts" asp-action="Index">
Discounts
</a>
</li>
}
@if (canViewTools)
{
<li class="nav-item dropdown" active-controller="tools">

View File

@@ -39,6 +39,11 @@ public static class StripeConstants
}
}
public static class CouponExpandablePropertyNames
{
public const string AppliesTo = "applies_to";
}
public static class ErrorCodes
{
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";

View File

@@ -6,8 +6,14 @@
/// </summary>
public enum DiscountAudienceType
{
/// <summary>
/// Discount applies to all users regardless of subscription history.
/// This is the default value (0) when audience restrictions are not applied.
/// </summary>
AllUsers = 0,
/// <summary>
/// Discount applies to users who have never had a subscription before.
/// </summary>
UserHasNoPreviousSubscriptions = 0
UserHasNoPreviousSubscriptions = 1
}

View File

@@ -48,4 +48,6 @@ public interface IStripeAdapter
Task CancelSetupIntentAsync(string id, SetupIntentCancelOptions options = null);
Task<SetupIntent> GetSetupIntentAsync(string id, SetupIntentGetOptions options = null);
Task<Price> GetPriceAsync(string id, PriceGetOptions options = null);
Task<Coupon> GetCouponAsync(string couponId, CouponGetOptions options = null);
Task<List<Product>> ListProductsAsync(ProductListOptions options = null);
}

View File

@@ -27,6 +27,8 @@ public class StripeAdapter : IStripeAdapter
private readonly TestClockService _testClockService;
private readonly CustomerBalanceTransactionService _customerBalanceTransactionService;
private readonly RegistrationService _taxRegistrationService;
private readonly CouponService _couponService;
private readonly ProductService _productService;
public StripeAdapter()
{
@@ -44,6 +46,8 @@ public class StripeAdapter : IStripeAdapter
_testClockService = new TestClockService();
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
_taxRegistrationService = new RegistrationService();
_couponService = new CouponService();
_productService = new ProductService();
}
/**************
@@ -209,4 +213,16 @@ public class StripeAdapter : IStripeAdapter
public Task<Card> DeleteCardAsync(string customerId, string cardId, CardDeleteOptions options = null) =>
_cardService.DeleteAsync(customerId, cardId, options);
/************
** COUPON **
************/
public Task<Coupon> GetCouponAsync(string couponId, CouponGetOptions options = null) =>
_couponService.GetAsync(couponId, options);
/*************
** PRODUCT **
*************/
public async Task<List<Product>> ListProductsAsync(ProductListOptions options = null) =>
(await _productService.ListAsync(options)).Data;
}

View File

@@ -20,4 +20,13 @@ public interface ISubscriptionDiscountRepository : IRepository<SubscriptionDisco
/// <param name="stripeCouponId">The Stripe coupon ID to search for.</param>
/// <returns>The subscription discount if found; otherwise, null.</returns>
Task<SubscriptionDiscount?> GetByStripeCouponIdAsync(string stripeCouponId);
/// <summary>
/// Lists subscription discounts with pagination support.
/// Returns discounts ordered by creation date descending (newest first).
/// </summary>
/// <param name="skip">Number of records to skip (for pagination).</param>
/// <param name="take">Number of records to take (page size).</param>
/// <returns>A collection of subscription discounts for the requested page.</returns>
Task<ICollection<SubscriptionDiscount>> ListAsync(int skip, int take);
}

View File

@@ -189,6 +189,7 @@ public static class FeatureFlagKeys
public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job";
public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode";
public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page";
public const string PM29108_EnablePersonalDiscounts = "pm-29108-enable-personal-discounts";
/* Key Management Team */
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";

View File

@@ -36,4 +36,16 @@ public class SubscriptionDiscountRepository(
return result;
}
public async Task<ICollection<SubscriptionDiscount>> ListAsync(int skip, int take)
{
using var sqlConnection = new SqlConnection(ReadOnlyConnectionString);
var results = await sqlConnection.QueryAsync<SubscriptionDiscount>(
"[dbo].[SubscriptionDiscount_List]",
new { Skip = skip, Take = take },
commandType: CommandType.StoredProcedure);
return results.ToArray();
}
}

View File

@@ -48,4 +48,18 @@ public class SubscriptionDiscountRepository(
return result == null ? null : Mapper.Map<SubscriptionDiscount>(result);
}
public async Task<ICollection<SubscriptionDiscount>> ListAsync(int skip, int take)
{
using var serviceScope = ServiceScopeFactory.CreateScope();
var databaseContext = GetDatabaseContext(serviceScope);
var results = await databaseContext.SubscriptionDiscounts
.OrderByDescending(sd => sd.CreationDate)
.Skip(skip)
.Take(take)
.ToArrayAsync();
return Mapper.Map<List<SubscriptionDiscount>>(results);
}
}

View File

@@ -0,0 +1,15 @@
CREATE PROCEDURE [dbo].[SubscriptionDiscount_List]
@Skip INT = 0,
@Take INT = 25
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[SubscriptionDiscountView]
ORDER BY [CreationDate] DESC
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END