Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
696597f
docs(billing): clarify cohort MigrationPathId nullability as Churn-on…
kdenney May 21, 2026
4f4e5ab
feat(billing): add read-only Cohorts page under Tools menu
kdenney May 21, 2026
a0d5720
feat(billing): compute cohort-type-aware Pending/Scheduled/Migrated c…
kdenney May 21, 2026
5903238
fix(billing): guard Migration Pending count against LEFT JOIN phantom…
kdenney May 21, 2026
03924ef
feat(billing): add CohortType discriminated value for cohort lifecycle
kdenney May 21, 2026
5882653
feat(admin): render Migration Path column from CohortType state
kdenney May 21, 2026
46392ac
feat(admin): add cohort name search and Previous/Next pagination
kdenney May 21, 2026
6e111fc
fix(admin): show cohort pagination when navigated past page 1 with em…
kdenney May 21, 2026
83219d1
feat(admin): scaffold cohort Create form for Migration cohorts
kdenney May 21, 2026
08272f0
feat(admin): enforce cohort-type cross-field validation rules
kdenney May 21, 2026
442669f
feat(billing): reject duplicate cohort names on create
kdenney May 21, 2026
307f365
feat(admin): validate cohort coupons against Stripe on create
kdenney May 21, 2026
b22ac87
feat(admin): add cohort Edit form with unregistered-path round-trip
kdenney May 21, 2026
9629a65
fix(admin): populate cohort ViewData on Edit POST re-render path
kdenney May 21, 2026
4a7e10f
feat(billing): delete cohorts only when no assignments have left Pending
kdenney May 21, 2026
72daa62
feat(admin): add Enable/Disable kill switch with Stripe revalidation …
kdenney May 21, 2026
4bc6f17
feat(admin): add type pill and coupon columns on the Cohorts list
kdenney May 21, 2026
913b609
style(admin): align cohort form and list view with design mockup
kdenney May 21, 2026
867a49e
revert(admin): restore cohort Name field to 255-character limit
kdenney May 21, 2026
f762027
feat(admin): gate cohort controller actions on feature flag
kdenney May 21, 2026
5bab311
renames
kdenney May 21, 2026
1bc6cf5
fix(admin): log exceptions when cohort creation fails
kdenney May 21, 2026
519b38d
renames and cleanup
kdenney May 22, 2026
ba5af62
fix(admin): cohort form layout, validation, and error surfacing
kdenney May 22, 2026
0aa32ac
feat(billing): add IsMigrationPathLocked rule to cohort entity
kdenney May 22, 2026
460a013
feat(admin): expose cohort migration-path lock state on Edit GET
kdenney May 22, 2026
befe783
feat(admin): pin cohort migration path when assignments have left Pen…
kdenney May 22, 2026
1418384
feat(admin): render locked migration path as static text on cohort Edit
kdenney May 22, 2026
4aab25e
docs(billing): note migration-path lock in GetCohortNonPendingAssignm…
kdenney May 22, 2026
d95d094
feat(billing): add GetCohortAssignmentStateQuery for cohort lock deci…
kdenney May 26, 2026
333aef0
refactor(billing): route cohort lock decisions through GetCohortAssig…
kdenney May 26, 2026
09ebcd3
refactor(billing): drop unused Models using from cohorts controller
kdenney May 26, 2026
9fb8a51
feat(admin): add EditCohortViewModel composing cohort form + display …
kdenney May 26, 2026
1746c13
refactor(admin): route cohort Edit through EditCohortViewModel, drop …
kdenney May 26, 2026
5fbdaf2
refactor(admin): move cohort entity → form-model mapping to CohortFor…
kdenney May 26, 2026
29ae784
feat(admin): align cohort Edit page with mockup
kdenney May 26, 2026
db9d555
refactor(admin): add _CohortFormFields partial for cohort name and co…
kdenney May 26, 2026
8f932d7
refactor(admin): eliminate cohort form prefix threading via _CohortFo…
kdenney May 26, 2026
c6fd18e
refactor(admin): persist cohort IsActive through Edit POST instead of…
kdenney May 26, 2026
58d4f82
feat(admin): replace cohort Enable/Disable card with Active checkbox
kdenney May 26, 2026
3fbab40
refactor(admin): drop Enable/Disable cohort controller actions
kdenney May 26, 2026
f988a6e
refactor(billing): remove unused OrganizationPlanMigrationCohort Upda…
kdenney May 26, 2026
241a236
chore(billing): delete unused OrganizationPlanMigrationCohort_UpdateI…
kdenney May 26, 2026
a204286
refactor(admin): use Html.ValidationMessage helper for migration path…
kdenney May 26, 2026
ab7725a
cleanup
kdenney May 27, 2026
ea7f5d9
refactor(sql): read cohort SPs from views and format SELECT one colum…
kdenney May 27, 2026
776c8c8
add clear search and fix is active checkbox
kdenney May 27, 2026
4dc4e39
fix migrations
kdenney May 27, 2026
9644db0
Merge branch 'main' into billing/PM-36951/cohorts-crud-ui
kdenney May 27, 2026
6d89d08
format?
kdenney May 27, 2026
855987d
migrations again
kdenney May 27, 2026
270fd4b
Merge branch 'main' into billing/pm-36964/determining-eligibility-add…
kdenney May 28, 2026
2e76592
clean up sql
kdenney May 28, 2026
5ba25d4
Merge branch 'main' into billing/PM-36951/cohorts-crud-ui
kdenney May 28, 2026
5e3ee5b
fix(admin): add id to cohort proactive coupon row for visibility togg…
kdenney May 28, 2026
4cf2beb
fix(admin): hide proactive coupon row and (optional) label on cohort …
kdenney May 28, 2026
127df22
fix(admin): hide proactive coupon row and (optional) label on cohort …
kdenney May 28, 2026
de59d33
fix(admin): add IsChurnOnly derived property to CohortFormModel
kdenney May 29, 2026
ee5e5f6
fix(admin): server-render hidden attribute on churn-only cohort form …
kdenney May 29, 2026
84fba3d
fix(admin): drop redundant initial applyVisibility call on cohort Create
kdenney May 29, 2026
aaa1c03
fix(admin): drop initial applyVisibility and Razor fallback on cohort…
kdenney May 29, 2026
6858116
Merge branch 'main' into billing/PM-36951/cohorts-crud-ui
kdenney May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
using System.ComponentModel.DataAnnotations;
using Bit.Admin.Billing.Models.OrganizationPlanMigrationCohorts;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Billing.Organizations.PlanMigration.Entities;
using Bit.Core.Billing.Organizations.PlanMigration.Queries;
using Bit.Core.Billing.Organizations.PlanMigration.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe;

// ReSharper disable InconsistentNaming
namespace Bit.Admin.Billing.Controllers;

[Authorize]
[Route("migration-cohorts")]
public class OrganizationPlanMigrationCohortsController(
IOrganizationPlanMigrationCohortRepository cohortRepository,
IStripeAdapter stripeAdapter,
ILogger<OrganizationPlanMigrationCohortsController> logger,
IFeatureService featureService,
IGetCohortAssignmentStateQuery getCohortAssignmentStateQuery) : Controller
{
private const int _defaultPageSize = 25;

private bool PlanMigrationCohortsFeatureEnabled() =>
featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration);

[HttpGet("")]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public async Task<IActionResult> Index(string? name = null, int page = 1, int count = _defaultPageSize)
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

if (page < 1) page = 1;
if (count < 1) count = 1;
var skip = (page - 1) * count;

var items = await cohortRepository.SearchWithCountsAsync(name, skip, count);

return View(new CohortsPagedModel
{
NameSearch = name,
Items = items.Select(CohortListItemViewModel.From).ToList(),
Page = page,
Count = count,
});
}

[HttpGet("create")]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public IActionResult Create()
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

return View(new CohortFormModel());
}

[HttpPost("create")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public async Task<IActionResult> Create(CohortFormModel model)
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

MergeCrossFieldValidationErrors(model);

if (!ModelState.IsValid)
{
return View(model);
}

try
{
if (!await ValidateNameAsync(model.Name) || !await ValidateCouponsAsync(model))
{
return View(model);
}

var cohort = new OrganizationPlanMigrationCohort
{
Name = model.Name,
MigrationPathId = model.GetMigrationPathId(),
ProactiveDiscountCouponCode = NormalizeCouponCode(model.ProactiveDiscountCouponCode),
ChurnDiscountCouponCode = NormalizeCouponCode(model.ChurnDiscountCouponCode),
};

await cohortRepository.CreateAsync(cohort);

TempData["Success"] = $"Cohort '{cohort.Name}' created.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
logger.LogError(ex, "Error creating cohort. Name: {Name}", model.Name);
ModelState.AddModelError(string.Empty, "An error occurred while saving the cohort.");
return View(model);
}
Comment on lines +96 to +101
}

[HttpGet("{id:guid}")]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public async Task<IActionResult> Edit(Guid id)
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

var cohort = await cohortRepository.GetByIdAsync(id);
if (cohort == null) return NotFound();

var assignmentState = await getCohortAssignmentStateQuery.Run(cohort);
return View(EditCohortViewModel.From(cohort, CohortFormModel.From(cohort), assignmentState));
}

[HttpPost("{id:guid}")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public async Task<IActionResult> Edit(Guid id, CohortFormModel model)
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

model.Id = id;

var cohort = await cohortRepository.GetByIdAsync(id);
if (cohort == null) return NotFound();

var assignmentState = await getCohortAssignmentStateQuery.Run(cohort);

if (assignmentState.HasNonPendingAssignments)
{
// The locked migration path view doesn't post a value for MigrationPathSelection.
// Restore from the persisted cohort so [Required] passes and the eventual
// ReplaceAsync writes back the unchanged path.
model.MigrationPathSelection = cohort.MigrationPathId switch
{
null => CohortFormModel.NoMigrationPath,
var pathId => ((byte)pathId).ToString(),
Comment thread
kdenney marked this conversation as resolved.
Dismissed
};
}

MergeCrossFieldValidationErrors(model);

if (!ModelState.IsValid)
{
return View(EditCohortViewModel.From(cohort, model, assignmentState));
}

try
{
if (!await ValidateNameAsync(model.Name, id)
|| !await ValidateCouponsAsync(model))
{
return View(EditCohortViewModel.From(cohort, model, assignmentState));
}

cohort.Name = model.Name;
cohort.MigrationPathId = model.GetMigrationPathId();
cohort.ProactiveDiscountCouponCode = NormalizeCouponCode(model.ProactiveDiscountCouponCode);
cohort.ChurnDiscountCouponCode = NormalizeCouponCode(model.ChurnDiscountCouponCode);
cohort.IsActive = model.IsActive;
cohort.RevisionDate = DateTime.UtcNow;

await cohortRepository.ReplaceAsync(cohort);

TempData["Success"] = $"Cohort '{cohort.Name}' updated.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
logger.LogError(ex, "Error updating cohort. Id: {Id}", id);
ModelState.AddModelError(string.Empty, "An error occurred while saving the cohort.");
return View(EditCohortViewModel.From(cohort, model, assignmentState));
}
Comment on lines +170 to +175
}

[HttpPost("{id:guid}/delete")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public async Task<IActionResult> Delete(Guid id)
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

var cohort = await cohortRepository.GetByIdAsync(id);
if (cohort == null) return NotFound();

try
{
var assignmentState = await getCohortAssignmentStateQuery.Run(cohort);
if (assignmentState.HasNonPendingAssignments)
{
TempData["Error"] =
$"Cannot delete cohort '{cohort.Name}' because {assignmentState.NonPendingAssignmentCount:N0} " +
"assignment(s) have left the Pending state. Historical migration and " +
"save-offer records are preserved.";
return RedirectToAction(nameof(Edit), new { id });
}

await cohortRepository.DeleteAsync(cohort);

TempData["Success"] = $"Cohort '{cohort.Name}' deleted.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
logger.LogError(ex, "Error deleting cohort. Id: {Id}", id);
TempData["Error"] = "An error occurred while attempting to delete the cohort.";
return RedirectToAction(nameof(Edit), new { id });
}
Comment on lines +205 to +210
}

private static string? NormalizeCouponCode(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();

// MVC skips IValidatableObject.Validate when any property-level attribute already failed, hiding cross-field
// rules until the operator resubmits. Run it explicitly so every error surfaces on a single submit.
// See https://github.com/dotnet/aspnetcore/issues/1899.
private void MergeCrossFieldValidationErrors(CohortFormModel model)
{
foreach (var result in model.Validate(new ValidationContext(model)))
{
foreach (var memberName in result.MemberNames.DefaultIfEmpty(string.Empty))
{
ModelState.AddModelError(memberName, result.ErrorMessage ?? string.Empty);
}
}
}

private async Task<bool> ValidateNameAsync(string name, Guid? excludeId = null)
{
var existing = await cohortRepository.GetByNameAsync(name);
if (existing == null || existing.Id == excludeId)
{
return true;
}

ModelState.AddModelError(nameof(CohortFormModel.Name), "A cohort with this name already exists.");
return false;
}

private async Task<bool> ValidateCouponsAsync(CohortFormModel model)
{
Comment thread
kdenney marked this conversation as resolved.
var proactive = NormalizeCouponCode(model.ProactiveDiscountCouponCode);
var churn = NormalizeCouponCode(model.ChurnDiscountCouponCode);

var ok = !(proactive != null && !await TryValidateCouponAsync(proactive, nameof(model.ProactiveDiscountCouponCode)));
if (churn != null && !await TryValidateCouponAsync(churn, nameof(model.ChurnDiscountCouponCode)))
{
ok = false;
}
return ok;
}

private async Task<bool> TryValidateCouponAsync(string couponId, string fieldName)
{
try
{
await stripeAdapter.GetCouponAsync(couponId);
return true;
}
catch (StripeException ex)
{
var message = 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}", couponId);
ModelState.AddModelError(fieldName, message);
return false;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Organizations.PlanMigration.Entities;
using Bit.Core.Billing.Organizations.PlanMigration.Enums;

namespace Bit.Admin.Billing.Models.OrganizationPlanMigrationCohorts;

public class CohortFormModel : IValidatableObject
{
public const string NoMigrationPath = "none";

public Guid? Id { get; set; }

[Required]
[MaxLength(255)]
[Display(Name = "Name")]
public string Name { get; set; } = string.Empty;

[Required(ErrorMessage = "Please select a migration path or None.")]
[Display(Name = "Migration path")]
public string MigrationPathSelection { get; set; } = string.Empty;

[MaxLength(64)]
[Display(Name = "Proactive discount coupon")]
public string? ProactiveDiscountCouponCode { get; set; }

[MaxLength(64)]
[Display(Name = "Churn discount coupon")]
public string? ChurnDiscountCouponCode { get; set; }

[Display(Name = "Active")]
public bool IsActive { get; set; }

public MigrationPathId? GetMigrationPathId()
{
if (MigrationPathSelection == NoMigrationPath)
{
return null;
}

if (byte.TryParse(MigrationPathSelection, out var id))
{
return (MigrationPathId)id;
}

throw new InvalidOperationException(
$"MigrationPathSelection '{MigrationPathSelection}' cannot be converted to MigrationPathId.");
}

public bool IsChurnOnly => MigrationPathSelection == NoMigrationPath;

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (MigrationPathSelection != NoMigrationPath)
yield break;

if (!string.IsNullOrEmpty(ProactiveDiscountCouponCode))
{
yield return new ValidationResult(
"Churn-only cohorts cannot have a proactive discount coupon.",
[nameof(ProactiveDiscountCouponCode)]);
}

if (string.IsNullOrEmpty(ChurnDiscountCouponCode))
{
yield return new ValidationResult(
"Churn discount coupon is required for Churn-only cohorts.",
[nameof(ChurnDiscountCouponCode)]);
}
}

public static CohortFormModel From(OrganizationPlanMigrationCohort cohort) => new()
{
Id = cohort.Id,
Name = cohort.Name,
MigrationPathSelection = cohort.MigrationPathId switch
{
null => NoMigrationPath,
var id => ((byte)id).ToString(),
},
ProactiveDiscountCouponCode = cohort.ProactiveDiscountCouponCode,
ChurnDiscountCouponCode = cohort.ChurnDiscountCouponCode,
IsActive = cohort.IsActive,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Bit.Core.Billing.Organizations.PlanMigration.Models;
using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects;

namespace Bit.Admin.Billing.Models.OrganizationPlanMigrationCohorts;

public class CohortListItemViewModel
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public CohortType CohortType { get; set; } = new CohortType.ChurnOnly();
public bool IsChurnOnly => CohortType is CohortType.ChurnOnly;
public bool IsActive { get; set; }
public string? ProactiveDiscountCouponCode { get; set; }
public string? ChurnDiscountCouponCode { get; set; }
public int Pending { get; set; }
public int Scheduled { get; set; }
public int Migrated { get; set; }

public static CohortListItemViewModel From(CohortListItem item) => new()
{
Id = item.Cohort.Id,
Name = item.Cohort.Name,
CohortType = CohortType.From(item.Cohort.MigrationPathId),
IsActive = item.Cohort.IsActive,
ProactiveDiscountCouponCode = item.Cohort.ProactiveDiscountCouponCode,
ChurnDiscountCouponCode = item.Cohort.ChurnDiscountCouponCode,
Pending = item.Pending,
Scheduled = item.Scheduled,
Migrated = item.Migrated,
};
}
Loading
Loading