1
0
mirror of https://github.com/bitwarden/server synced 2026-02-18 10:23:27 +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

View File

@@ -0,0 +1,459 @@
using Bit.Admin.Billing.Controllers;
using Bit.Admin.Billing.Models;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Entities;
using Bit.Core.Billing.Subscriptions.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Stripe;
namespace Admin.Test.Billing.Controllers;
[ControllerCustomize(typeof(SubscriptionDiscountsController))]
[SutProviderCustomize]
public class SubscriptionDiscountsControllerTests
{
[Theory, BitAutoData]
public async Task Index_DefaultParameters_ReturnsViewWithDiscounts(
List<SubscriptionDiscount> discounts,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.ListAsync(0, 25)
.Returns(discounts);
var result = await sutProvider.Sut.Index();
Assert.IsType<ViewResult>(result);
var viewResult = (ViewResult)result;
var model = Assert.IsType<SubscriptionDiscountPagedModel>(viewResult.Model);
Assert.Equal(25, model.Count);
Assert.Equal(1, model.Page);
Assert.Equal(discounts.Count, model.Items.Count);
}
[Theory, BitAutoData]
public async Task Index_WithPagination_CalculatesCorrectSkip(
List<SubscriptionDiscount> discounts,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.ListAsync(50, 25)
.Returns(discounts);
var result = await sutProvider.Sut.Index(page: 3, count: 25);
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.Received(1)
.ListAsync(50, 25);
}
[Theory, BitAutoData]
public async Task Index_WithInvalidPage_DefaultsToPage1(
List<SubscriptionDiscount> discounts,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.ListAsync(0, 25)
.Returns(discounts);
var result = await sutProvider.Sut.Index(page: -1, count: 25);
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.Received(1)
.ListAsync(0, 25);
}
[Theory, BitAutoData]
public void Create_Get_ReturnsViewWithEmptyModel(
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var result = sutProvider.Sut.Create();
Assert.IsType<ViewResult>(result);
var viewResult = (ViewResult)result;
var model = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
Assert.False(model.IsImported);
}
[Theory, BitAutoData]
public async Task ImportCoupon_ValidCoupon_ReturnsViewWithStripeProperties(
CreateSubscriptionDiscountModel model,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var stripeCoupon = new Stripe.Coupon
{
Name = "Test Coupon",
PercentOff = 25,
Duration = "once"
};
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.GetDependency<IStripeAdapter>()
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
.Returns(stripeCoupon);
var result = await sutProvider.Sut.ImportCoupon(model);
Assert.IsType<ViewResult>(result);
var viewResult = (ViewResult)result;
Assert.Equal("Create", viewResult.ViewName);
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
Assert.Equal(stripeCoupon.Name, returnedModel.Name);
Assert.Equal(stripeCoupon.PercentOff, returnedModel.PercentOff);
}
[Theory, BitAutoData]
public async Task ImportCoupon_ValidCoupon_SetsIsImportedToTrue(
CreateSubscriptionDiscountModel model,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var stripeCoupon = new Stripe.Coupon
{
Name = "Test Coupon",
PercentOff = 25,
Duration = "once"
};
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.GetDependency<IStripeAdapter>()
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
.Returns(stripeCoupon);
// Ensure IsImported starts as false
model.IsImported = false;
var result = await sutProvider.Sut.ImportCoupon(model);
var viewResult = Assert.IsType<ViewResult>(result);
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
Assert.True(returnedModel.IsImported, "IsImported should be set to true after successful import");
}
[Theory, BitAutoData]
public async Task ImportCoupon_CouponWithProductRestrictions_MapsProductIds(
CreateSubscriptionDiscountModel model,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var productIds = new List<string> { "prod_test1", "prod_test2", "prod_test3" };
var stripeCoupon = new Stripe.Coupon
{
Name = "Test Coupon",
PercentOff = 25,
Duration = "once",
AppliesTo = new Stripe.CouponAppliesTo
{
Products = productIds
}
};
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.GetDependency<IStripeAdapter>()
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
.Returns(stripeCoupon);
var products = new List<Stripe.Product>
{
new() { Id = "prod_test1", Name = "Test Product 1" },
new() { Id = "prod_test2", Name = "Test Product 2" },
new() { Id = "prod_test3", Name = "Test Product 3" }
};
sutProvider.GetDependency<IStripeAdapter>()
.ListProductsAsync(Arg.Is<ProductListOptions>(o =>
o.Ids != null &&
o.Ids.Count == 3 &&
o.Ids.Contains("prod_test1") &&
o.Ids.Contains("prod_test2") &&
o.Ids.Contains("prod_test3")))
.Returns(products);
var result = await sutProvider.Sut.ImportCoupon(model);
Assert.IsType<ViewResult>(result);
var viewResult = (ViewResult)result;
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
Assert.NotNull(returnedModel.AppliesToProducts);
Assert.Equal(3, returnedModel.AppliesToProducts.Count);
Assert.Equal("Test Product 1", returnedModel.AppliesToProducts["prod_test1"]);
Assert.Equal("Test Product 2", returnedModel.AppliesToProducts["prod_test2"]);
Assert.Equal("Test Product 3", returnedModel.AppliesToProducts["prod_test3"]);
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.ListProductsAsync(Arg.Is<ProductListOptions>(o => o.Ids != null && o.Ids.Count == 3));
}
[Theory, BitAutoData]
public async Task ImportCoupon_EmptyCouponId_ReturnsViewWithError(
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var model = new CreateSubscriptionDiscountModel { StripeCouponId = "" };
sutProvider.Sut.ModelState.AddModelError(nameof(model.StripeCouponId), "The Stripe Coupon ID field is required.");
var result = await sutProvider.Sut.ImportCoupon(model);
Assert.IsType<ViewResult>(result);
var viewResult = (ViewResult)result;
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("required", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
}
[Theory, BitAutoData]
public async Task ImportCoupon_DuplicateCoupon_ReturnsViewWithError(
CreateSubscriptionDiscountModel model,
SubscriptionDiscount existingDiscount,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns(existingDiscount);
var result = await sutProvider.Sut.ImportCoupon(model);
Assert.IsType<ViewResult>(result);
var viewResult = (ViewResult)result;
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("already been imported", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
}
[Theory, BitAutoData]
public async Task ImportCoupon_StripeApiError_ReturnsViewWithError(
CreateSubscriptionDiscountModel model,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.GetDependency<IStripeAdapter>()
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
.Throws(new StripeException());
var result = await sutProvider.Sut.ImportCoupon(model);
Assert.IsType<ViewResult>(result);
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("error occurred", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
}
[Theory, BitAutoData]
public async Task ImportCoupon_StripeResourceMissingError_ReturnsViewWithSpecificError(
CreateSubscriptionDiscountModel model,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
var stripeError = new StripeError { Code = "resource_missing" };
var stripeException = new StripeException { StripeError = stripeError };
sutProvider.GetDependency<IStripeAdapter>()
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
.Throws(stripeException);
var result = await sutProvider.Sut.ImportCoupon(model);
Assert.IsType<ViewResult>(result);
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("not found in Stripe", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
}
[Theory, BitAutoData]
public async Task ImportCoupon_WithDurationInMonths_ConvertsToInt(
CreateSubscriptionDiscountModel model,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var stripeCoupon = new Stripe.Coupon
{
Name = "Test Coupon",
PercentOff = 25,
Duration = "repeating",
DurationInMonths = 12L
};
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.GetDependency<IStripeAdapter>()
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions>())
.Returns(stripeCoupon);
var result = await sutProvider.Sut.ImportCoupon(model);
var viewResult = (ViewResult)result;
var returnedModel = Assert.IsType<CreateSubscriptionDiscountModel>(viewResult.Model);
Assert.Equal(12, returnedModel.DurationInMonths);
}
[Theory, BitAutoData]
public async Task Create_ValidModel_CreatesDiscountAndRedirects(
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var model = new CreateSubscriptionDiscountModel
{
StripeCouponId = "TEST123",
Name = "Test Coupon",
PercentOff = 25,
Duration = "once",
StartDate = DateTime.UtcNow.Date,
EndDate = DateTime.UtcNow.Date.AddMonths(1),
RestrictToNewUsersOnly = false,
IsImported = true
};
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.Sut.ModelState.Clear();
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
sutProvider.Sut.TempData = tempData;
var result = await sutProvider.Sut.Create(model);
Assert.IsType<RedirectToActionResult>(result);
var redirectResult = (RedirectToActionResult)result;
Assert.Equal(nameof(SubscriptionDiscountsController.Index), redirectResult.ActionName);
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.Received(1)
.CreateAsync(Arg.Is<SubscriptionDiscount>(d =>
d.StripeCouponId == model.StripeCouponId &&
d.Name == model.Name &&
d.StartDate == model.StartDate &&
d.EndDate == model.EndDate &&
d.AudienceType == DiscountAudienceType.AllUsers));
}
[Theory, BitAutoData]
public async Task Create_WithRestrictToNewUsersOnly_SetsCorrectAudienceType(
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var model = new CreateSubscriptionDiscountModel
{
StripeCouponId = "TEST123",
Name = "Test Coupon",
PercentOff = 25,
Duration = "once",
StartDate = DateTime.UtcNow.Date,
EndDate = DateTime.UtcNow.Date.AddMonths(1),
RestrictToNewUsersOnly = true,
IsImported = true
};
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns((SubscriptionDiscount?)null);
sutProvider.Sut.ModelState.Clear();
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
sutProvider.Sut.TempData = tempData;
var result = await sutProvider.Sut.Create(model);
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.Received(1)
.CreateAsync(Arg.Is<SubscriptionDiscount>(d =>
d.AudienceType == DiscountAudienceType.UserHasNoPreviousSubscriptions));
}
[Theory, BitAutoData]
public async Task Create_NotImported_ReturnsViewWithError(
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var model = new CreateSubscriptionDiscountModel
{
StripeCouponId = "TEST123",
Name = null
};
var result = await sutProvider.Sut.Create(model);
Assert.IsType<ViewResult>(result);
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("import the coupon", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
}
[Theory, BitAutoData]
public async Task Create_DuplicateCoupon_ReturnsViewWithError(
SubscriptionDiscount existingDiscount,
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var model = new CreateSubscriptionDiscountModel
{
StripeCouponId = "TEST123",
Name = "Test Coupon",
PercentOff = 25,
Duration = "once",
StartDate = DateTime.UtcNow.Date,
EndDate = DateTime.UtcNow.Date.AddMonths(1),
IsImported = true
};
// Simulate race condition: another admin imported the same coupon between import and save
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(model.StripeCouponId)
.Returns(existingDiscount);
sutProvider.Sut.ModelState.Clear();
var result = await sutProvider.Sut.Create(model);
Assert.IsType<ViewResult>(result);
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("already been imported", sutProvider.Sut.ModelState[nameof(model.StripeCouponId)]!.Errors[0].ErrorMessage);
// Verify CreateAsync was NOT called since we detected the duplicate
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<SubscriptionDiscount>());
}
[Theory, BitAutoData]
public async Task Create_RepositoryThrowsException_ReturnsViewWithError(
SutProvider<SubscriptionDiscountsController> sutProvider)
{
var model = new CreateSubscriptionDiscountModel
{
StripeCouponId = "TEST123",
Name = "Test Coupon",
PercentOff = 25,
Duration = "once",
StartDate = DateTime.UtcNow.Date,
EndDate = DateTime.UtcNow.Date.AddMonths(1),
IsImported = true
};
sutProvider.Sut.ModelState.Clear();
var tempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
sutProvider.Sut.TempData = tempData;
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.CreateAsync(Arg.Any<SubscriptionDiscount>())
.Throws(new Exception("Database error"));
var result = await sutProvider.Sut.Create(model);
Assert.IsType<ViewResult>(result);
Assert.False(sutProvider.Sut.ModelState.IsValid);
Assert.Contains("error occurred", sutProvider.Sut.ModelState[string.Empty]!.Errors[0].ErrorMessage);
}
}

View File

@@ -0,0 +1,76 @@
using Bit.Admin.Billing.Models;
using Bit.Core.Billing.Enums;
namespace Admin.Test.Billing.Models;
public class CreateSubscriptionDiscountModelTests
{
[Fact]
public void AudienceType_WhenCheckboxUnchecked_ReturnsAllUsers()
{
var model = new CreateSubscriptionDiscountModel
{
RestrictToNewUsersOnly = false
};
Assert.Equal(DiscountAudienceType.AllUsers, model.AudienceType);
}
[Fact]
public void AudienceType_WhenCheckboxChecked_ReturnsUserHasNoPreviousSubscriptions()
{
var model = new CreateSubscriptionDiscountModel
{
RestrictToNewUsersOnly = true
};
Assert.Equal(DiscountAudienceType.UserHasNoPreviousSubscriptions, model.AudienceType);
}
[Fact]
public void Validate_WhenEndDateBeforeStartDate_ReturnsError()
{
var model = new CreateSubscriptionDiscountModel
{
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 CreateSubscriptionDiscountModel
{
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 CreateSubscriptionDiscountModel
{
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);
}
}

View File

@@ -0,0 +1,168 @@
using Bit.Admin.Billing.Models;
using Bit.Core.Billing.Enums;
namespace Admin.Test.Billing.Models;
public class SubscriptionDiscountViewModelTests
{
[Fact]
public void DiscountDisplay_WithPercentOff_ReturnsFormattedPercent()
{
var model = new SubscriptionDiscountViewModel
{
PercentOff = 25m
};
Assert.Equal("25% off", model.DiscountDisplay);
}
[Fact]
public void DiscountDisplay_WithDecimalPercentOff_ReturnsFormattedPercentWithDecimals()
{
var model = new SubscriptionDiscountViewModel
{
PercentOff = 33.5m
};
Assert.Equal("33.5% off", model.DiscountDisplay);
}
[Fact]
public void DiscountDisplay_WithWholeNumberPercentOff_ReturnsFormattedPercentWithoutDecimals()
{
var model = new SubscriptionDiscountViewModel
{
PercentOff = 50.00m
};
Assert.Equal("50% off", model.DiscountDisplay);
}
[Fact]
public void DiscountDisplay_WithAmountOff_ReturnsFormattedDollar()
{
var model = new SubscriptionDiscountViewModel
{
AmountOff = 1000
};
Assert.Equal("$10 off", model.DiscountDisplay);
}
[Fact]
public void DiscountDisplay_WithZeroAmountOff_ReturnsZero()
{
var model = new SubscriptionDiscountViewModel
{
AmountOff = 0
};
Assert.Equal("$0 off", model.DiscountDisplay);
}
[Fact]
public void IsRestrictedToNewUsersOnly_WithMatchingAudienceType_ReturnsTrue()
{
var model = new SubscriptionDiscountViewModel
{
AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
};
Assert.True(model.IsRestrictedToNewUsersOnly);
}
[Fact]
public void IsRestrictedToNewUsersOnly_WithAllUsersAudienceType_ReturnsFalse()
{
var model = new SubscriptionDiscountViewModel
{
AudienceType = DiscountAudienceType.AllUsers
};
Assert.False(model.IsRestrictedToNewUsersOnly);
}
[Fact]
public void IsAvailableToAllUsers_WithAllUsersAudienceType_ReturnsTrue()
{
var model = new SubscriptionDiscountViewModel
{
AudienceType = DiscountAudienceType.AllUsers
};
Assert.True(model.IsAvailableToAllUsers);
}
[Fact]
public void IsAvailableToAllUsers_WithRestrictedAudienceType_ReturnsFalse()
{
var model = new SubscriptionDiscountViewModel
{
AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions
};
Assert.False(model.IsAvailableToAllUsers);
}
[Fact]
public void IsActive_WhenWithinDateRange_ReturnsTrue()
{
var model = new SubscriptionDiscountViewModel
{
StartDate = DateTime.UtcNow.AddDays(-1),
EndDate = DateTime.UtcNow.AddDays(1)
};
Assert.True(model.IsActive);
}
[Fact]
public void IsActive_WhenBeforeStartDate_ReturnsFalse()
{
var model = new SubscriptionDiscountViewModel
{
StartDate = DateTime.UtcNow.AddDays(1),
EndDate = DateTime.UtcNow.AddDays(2)
};
Assert.False(model.IsActive);
}
[Fact]
public void IsActive_WhenAfterEndDate_ReturnsFalse()
{
var model = new SubscriptionDiscountViewModel
{
StartDate = DateTime.UtcNow.AddDays(-2),
EndDate = DateTime.UtcNow.AddDays(-1)
};
Assert.False(model.IsActive);
}
[Fact]
public void IsActive_WhenExactlyOnStartDate_ReturnsTrue()
{
var now = DateTime.UtcNow;
var model = new SubscriptionDiscountViewModel
{
StartDate = now,
EndDate = now.AddDays(1)
};
Assert.True(model.IsActive);
}
[Fact]
public void IsActive_WhenCurrentTimeIsOnEndDate_ReturnsTrue()
{
var now = DateTime.UtcNow;
var model = new SubscriptionDiscountViewModel
{
StartDate = now.AddDays(-1),
EndDate = now.AddSeconds(1)
};
Assert.True(model.IsActive);
}
}

View File

@@ -79,6 +79,11 @@ public class SubscriptionDiscountRepositoryTests
Assert.Contains(activeDiscounts, d => d.Id == activeDiscount.Id);
Assert.DoesNotContain(activeDiscounts, d => d.Id == expiredDiscount.Id);
Assert.DoesNotContain(activeDiscounts, d => d.Id == futureDiscount.Id);
// Cleanup
await subscriptionDiscountRepository.DeleteAsync(activeDiscount);
await subscriptionDiscountRepository.DeleteAsync(expiredDiscount);
await subscriptionDiscountRepository.DeleteAsync(futureDiscount);
}
[Theory, DatabaseData]
@@ -106,6 +111,9 @@ public class SubscriptionDiscountRepositoryTests
Assert.Equal(couponId, result.StripeCouponId);
Assert.Equal(20.00m, result.PercentOff);
Assert.Equal(3, result.DurationInMonths);
// Cleanup
await subscriptionDiscountRepository.DeleteAsync(discount);
}
[Theory, DatabaseData]
@@ -140,6 +148,9 @@ public class SubscriptionDiscountRepositoryTests
Assert.Equal(discount.StripeCouponId, createdDiscount.StripeCouponId);
Assert.Equal(500, createdDiscount.AmountOff);
Assert.Equal("usd", createdDiscount.Currency);
// Cleanup
await subscriptionDiscountRepository.DeleteAsync(createdDiscount);
}
[Theory, DatabaseData]
@@ -164,6 +175,9 @@ public class SubscriptionDiscountRepositoryTests
Assert.NotNull(updatedDiscount);
Assert.Equal("Updated Name", updatedDiscount.Name);
Assert.Equal(15.00m, updatedDiscount.PercentOff);
// Cleanup
await subscriptionDiscountRepository.DeleteAsync(updatedDiscount);
}
[Theory, DatabaseData]
@@ -184,4 +198,123 @@ public class SubscriptionDiscountRepositoryTests
var deletedDiscount = await subscriptionDiscountRepository.GetByIdAsync(discount.Id);
Assert.Null(deletedDiscount);
}
[Theory, DatabaseData]
public async Task ListAsync_ReturnsPagedResults_OrderedByCreationDateDescending(
ISubscriptionDiscountRepository subscriptionDiscountRepository)
{
// Arrange - create discounts with future timestamps (should be at top)
var farFuture = new DateTime(2500, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var discount1 = await subscriptionDiscountRepository.CreateAsync(
CreateTestDiscount(
stripeCouponId: $"test-search-1-{Guid.NewGuid()}",
percentOff: 10.00m,
name: "First Discount",
creationDate: farFuture.AddSeconds(-3)));
var discount2 = await subscriptionDiscountRepository.CreateAsync(
CreateTestDiscount(
stripeCouponId: $"test-search-2-{Guid.NewGuid()}",
percentOff: 20.00m,
name: "Second Discount",
creationDate: farFuture.AddSeconds(-2)));
var discount3 = await subscriptionDiscountRepository.CreateAsync(
CreateTestDiscount(
stripeCouponId: $"test-search-3-{Guid.NewGuid()}",
percentOff: 30.00m,
name: "Third Discount",
creationDate: farFuture.AddSeconds(-1)));
// Act - get first page
var result = await subscriptionDiscountRepository.ListAsync(0, 100);
// Assert
Assert.NotEmpty(result);
var resultList = result.ToList();
// Our discounts should be the first 3 in the result
Assert.Equal(discount3.Id, resultList[0].Id);
Assert.Equal(discount2.Id, resultList[1].Id);
Assert.Equal(discount1.Id, resultList[2].Id);
// Cleanup
await subscriptionDiscountRepository.DeleteAsync(discount1);
await subscriptionDiscountRepository.DeleteAsync(discount2);
await subscriptionDiscountRepository.DeleteAsync(discount3);
}
[Theory, DatabaseData]
public async Task ListAsync_WithSkip_ReturnsCorrectPage(
ISubscriptionDiscountRepository subscriptionDiscountRepository)
{
// Arrange - create several discounts with future timestamps (should be at top)
var farFuture = new DateTime(2500, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var discounts = new List<SubscriptionDiscount>();
for (int i = 0; i < 5; i++)
{
var discount = await subscriptionDiscountRepository.CreateAsync(
CreateTestDiscount(
stripeCouponId: $"test-skip-{i}-{Guid.NewGuid()}",
percentOff: 10.00m + i,
name: $"Discount {i}",
creationDate: farFuture.AddSeconds(-i)));
discounts.Add(discount);
}
// Act - get first page to find where our discounts are
var allResults = await subscriptionDiscountRepository.ListAsync(0, 100);
var allResultsList = allResults.ToList();
// Find the indices of our created discounts
var indices = discounts.Select(d => allResultsList.FindIndex(r => r.Id == d.Id)).Where(i => i >= 0).OrderBy(i => i).ToList();
// Act - skip the first 2 of OUR discounts, take 2
var result = await subscriptionDiscountRepository.ListAsync(indices[2], 2);
// Assert
var resultList = result.ToList();
Assert.True(resultList.Count == 2);
// Verify we got discounts[2] and discounts[3]
Assert.Contains(resultList, d => d.Id == discounts[2].Id);
Assert.Contains(resultList, d => d.Id == discounts[3].Id);
// Cleanup
foreach (var discount in discounts)
{
await subscriptionDiscountRepository.DeleteAsync(discount);
}
}
[Theory, DatabaseData]
public async Task ListAsync_WithTake_LimitsResults(
ISubscriptionDiscountRepository subscriptionDiscountRepository)
{
// Arrange - create 5 discounts
var discounts = new List<SubscriptionDiscount>();
for (int i = 0; i < 5; i++)
{
var discount = await subscriptionDiscountRepository.CreateAsync(
CreateTestDiscount(
stripeCouponId: $"test-limit-{i}-{Guid.NewGuid()}",
percentOff: 10.00m,
name: $"Discount {i}",
creationDate: DateTime.UtcNow.AddMinutes(-i)));
discounts.Add(discount);
}
// Act - get only 3 results
var result = await subscriptionDiscountRepository.ListAsync(0, 3);
// Assert
var resultList = result.ToList();
Assert.True(resultList.Count == 3);
// Cleanup
foreach (var discount in discounts)
{
await subscriptionDiscountRepository.DeleteAsync(discount);
}
}
}

View File

@@ -0,0 +1,17 @@
-- Add SubscriptionDiscount_List stored procedure for pagination
CREATE OR ALTER 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
GO