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:
209
src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs
Normal file
209
src/Admin/Billing/Controllers/SubscriptionDiscountsController.cs
Normal 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;
|
||||
}
|
||||
47
src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs
Normal file
47
src/Admin/Billing/Models/CreateSubscriptionDiscountModel.cs
Normal 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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using Bit.Admin.Models;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
public class SubscriptionDiscountPagedModel : PagedModel<SubscriptionDiscountViewModel>
|
||||
{
|
||||
}
|
||||
27
src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs
Normal file
27
src/Admin/Billing/Models/SubscriptionDiscountViewModel.cs
Normal 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;
|
||||
}
|
||||
17
src/Admin/Billing/Views/SubscriptionDiscounts/Create.cshtml
Normal file
17
src/Admin/Billing/Views/SubscriptionDiscounts/Create.cshtml
Normal 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" />
|
||||
}
|
||||
127
src/Admin/Billing/Views/SubscriptionDiscounts/Index.cshtml
Normal file
127
src/Admin/Billing/Views/SubscriptionDiscounts/Index.cshtml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
15
src/Sql/dbo/Stored Procedures/SubscriptionDiscount_List.sql
Normal file
15
src/Sql/dbo/Stored Procedures/SubscriptionDiscount_List.sql
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user