Skip to content

[PM-36951] Add Billing Plan Migration Cohorts Admin UI#7732

Open
kdenney wants to merge 62 commits into
mainfrom
billing/pm-36964/determining-eligibility-add-per-org-cohort-assignment-to-the-admin-portal
Open

[PM-36951] Add Billing Plan Migration Cohorts Admin UI#7732
kdenney wants to merge 62 commits into
mainfrom
billing/pm-36964/determining-eligibility-add-per-org-cohort-assignment-to-the-admin-portal

Conversation

@kdenney
Copy link
Copy Markdown
Contributor

@kdenney kdenney commented May 27, 2026

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-36951

📔 Objective

Adds the operator-facing Cohorts CRUD UI under the Tools menu of the Bitwarden Portal (src/Admin), gated by the PM35215_BusinessPlanPriceMigration feature flag. This is the workflow operators use to define, search, edit, and disable the Migration and Churn-only cohorts that drive the business-plan price migration loop introduced in PM-35215.

What this adds:

  • Cohorts list page with name search, Previous/Next pagination, and a Clear search link that surfaces only when a filter is applied. Columns cover Migration Path, Type, coupon codes, and Pending/Scheduled/Migrated counts.
  • Create form scaffolded for the Migration / Churn-only cohort split, with cohort-type-aware cross-field validation (churn coupon required on Churn-only, proactive coupon disallowed on Churn-only, etc.).
  • Edit form composed via EditCohortViewModel, with a shared _CohortFormFields Razor partial driving Name and coupon inputs across Create and Edit. Active state lives on the form and is persisted through the same POST.
  • Migration-path lock: once a cohort has assignments that have left Pending, the migration-path field renders as static text and the controller ignores any submitted change. Lock decisions flow through a dedicated GetCohortAssignmentStateQuery.
  • Stripe coupon validation on save (proactive + churn), duplicate-name rejection, and a delete path that only succeeds when no assignments have left Pending.
  • New stored procedures: OrganizationPlanMigrationCohort_SearchWithCounts, OrganizationPlanMigrationCohort_ReadByName, OrganizationPlanMigrationCohortAssignment_CountNonPendingByCohortId.

Where this deviates from the mockups and why

  • The migration path dropdown was moved to the top of the form to enable the option of using a razor partial for the fields shared by the create and edit forms more easily but also so that the "locked path" shows more prominently at the top of the form when editing a locked cohort
  • Added a visual lock indicator (see screenshots) when a cohort is locked and cannot change the migration path. (cohorts are "locked" when at least one organization assigned to the cohort has been migrated.
  • Removed the "is active" checkbox from the create form because it defaults to OFF, and there's no real reason to activate a cohort before assignments are even loaded.
  • Churn only cohorts list the number of organizations that have activated the churn coupon in the "Migrated" column. The mockup had a "-" in that column, but it is data we are able to represent so it was added to add benefit.
  • A "clear search" link was added next to the search box when the user is searching for something to enable an easy way to return to the full list.

📸 Screenshots

image image image image image

kdenney added 30 commits May 27, 2026 10:59
@kdenney kdenney added the ai-review Request a Claude code review label May 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the Cohorts CRUD admin UI for the business-plan price migration loop, including the Razor views, controller, dual-ORM repository implementations (Dapper + EF Core), stored procedures with corresponding migration scripts, value objects, and the supporting unit/integration tests. The change is feature-flag gated (PM35215_BusinessPlanPriceMigration), permission-gated (Tools_ManagePlanMigrationCohorts), and adds defensive logic for the migration-path lock once assignments leave the Pending state. No security, correctness, or breaking-change issues meet the bar for an inline finding.

Code Review Details

No findings.

Notable strengths observed (for context only — not actionable):

  • The locked-path restoration in Edit (controller lines 131-141) correctly forces re-validation against the persisted MigrationPathId, and the test Edit_Post_LockedCohort_IgnoresSubmittedMigrationPathSelection confirms a forged path is dropped.
  • MergeCrossFieldValidationErrors works around the known MVC behavior where IValidatableObject.Validate is skipped after property-level failures.
  • The Dapper SP and EF LINQ implementations of SearchWithCountsAsync, GetByNameAsync, and GetCohortNonPendingAssignmentsCountAsync are kept in lockstep, with parity validated through Infrastructure.IntegrationTest.
  • The CohortType discriminated record with the UnresolvedMigration case gives the Edit UI a graceful path for byte values that no longer map to a known MigrationPath.
  • Unique-name constraint is enforced both at the application layer (ValidateNameAsync) and the database layer (IX_OrganizationPlanMigrationCohort_Name).

Open reviewer threads from amorask-bitwarden are either acknowledged (the Stripe round-trip thread is resolved by author decision) or appear to be addressed by the recent server-rendered hidden attribute commits on _CohortFormFields.cshtml. No need for a duplicate Claude comment.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 27, 2026

Codecov Report

❌ Patch coverage is 70.43880% with 128 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.14%. Comparing base (967be3e) to head (6858116).

Files with missing lines Patch % Lines
...iews/OrganizationPlanMigrationCohorts/Index.cshtml 0.00% 54 Missing ⚠️
...lers/OrganizationPlanMigrationCohortsController.cs 77.35% 23 Missing and 13 partials ⚠️
...Views/OrganizationPlanMigrationCohorts/Edit.cshtml 0.00% 17 Missing ⚠️
src/Admin/Views/Shared/_Layout.cshtml 0.00% 7 Missing ⚠️
...rganizationPlanMigrationCohorts/CohortFormModel.cs 89.13% 3 Missing and 2 partials ⚠️
...ews/OrganizationPlanMigrationCohorts/Create.cshtml 0.00% 4 Missing ⚠️
...nizations/PlanMigration/ValueObjects/CohortType.cs 84.61% 2 Missing ⚠️
...ionPlanMigrationCohorts/CohortListItemViewModel.cs 95.45% 1 Missing ⚠️
...ories/OrganizationPlanMigrationCohortRepository.cs 96.15% 0 Missing and 1 partial ⚠️
...ories/OrganizationPlanMigrationCohortRepository.cs 97.61% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7732      +/-   ##
==========================================
+ Coverage   60.69%   65.14%   +4.45%     
==========================================
  Files        2154     2166      +12     
  Lines       95523    95955     +432     
  Branches     8560     8633      +73     
==========================================
+ Hits        57978    62511    +4533     
+ Misses      35498    31289    -4209     
- Partials     2047     2155     +108     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kdenney kdenney changed the title Billing/pm 36964/determining eligibility add per org cohort assignment to the admin portal [PM-36951] Add Billing Plan Migration Cohorts Admin UI May 28, 2026
@kdenney kdenney marked this pull request as ready for review May 28, 2026 16:14
@kdenney kdenney requested review from a team as code owners May 28, 2026 16:14
@kdenney kdenney requested a review from sbrown-livefront May 28, 2026 16:14
@amorask-bitwarden amorask-bitwarden self-requested a review May 28, 2026 16:32
Copy link
Copy Markdown
Contributor

@amorask-bitwarden amorask-bitwarden left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Killer work here - this looks great. Just a couple ⛏️ / 💡 if you want to address them, but looks good to me.

Comment thread src/Admin/Billing/Views/OrganizationPlanMigrationCohorts/_CohortFormFields.cshtml Outdated
kdenney added 8 commits May 28, 2026 17:22
…ling

Adds id="proactive-coupon-row" to the wrapping div in the shared cohort
form partial. Scripts in Create.cshtml and Edit.cshtml will toggle the
row's hidden property based on whether the cohort is churn-only.
…Create

Replaces the cohort Create script with a single applyVisibility helper that
toggles both the proactive coupon row and the churn discount (optional)
label whenever the migration path is "None (Churn-only)". Runs on load to
cover validation-error redisplay where the user previously picked churn-only.
…Edit

Replaces the cohort Edit script with a single applyVisibility helper that
toggles both the proactive coupon row and the churn discount (optional)
label whenever the migration path is "None (Churn-only)". Falls back to a
Razor-rendered constant when the migration-path select is absent (locked
cohorts), so locked churn-only cohorts no longer render (optional) on the
required churn discount field.
Exposes a typed answer to "is this form representing a churn-only cohort?"
alongside the existing GetMigrationPathId() method. Consumers no longer need
to re-compare MigrationPathSelection against the "none" sentinel themselves.
…fields

Conditionally renders the hidden attribute on the proactive coupon row and
the churn discount (optional) label based on Model.IsChurnOnly, so the
browser never paints them visible before the page-load script can hide them.
Eliminates the FOUC observed on locked churn-only Edit and on Create after
churn-only validation redisplay.
The server now renders the hidden attribute when MigrationPathSelection is
"none" (validation-error redisplay), so the page-load script no longer needs
to re-set visibility on load. Keeps only the change listener for interactive
toggling.
… Edit

The server now renders the hidden attribute on initial load for every Edit
scenario (locked, unlocked, churn-only, migration), so the page-load script
no longer needs the Razor-rendered MigrationPathSelection constant or the
initial applyVisibility call. Keeps only the change listener for interactive
toggling on the unlocked path.
# Conflicts:
#	src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
@sonarqubecloud
Copy link
Copy Markdown

Comment thread src/Admin/Billing/Controllers/OrganizationPlanMigrationCohortsController.cs Dismissed
MigrationPathSelection = cohort.MigrationPathId switch
{
null => NoMigrationPath,
var id => ((byte)id).ToString(),
.GetCouponAsync(Arg.Any<string>(), Arg.Any<CouponGetOptions?>())
.ThrowsAsync(new StripeException { StripeError = new StripeError { Code = "resource_missing" } });

var result = await sutProvider.Sut.Create(model);
Comment on lines +96 to +101
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 +170 to +175
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 +205 to +210
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 });
}
.CreateAsync(Arg.Is<OrganizationPlanMigrationCohort>(c =>
c.Name == model.Name
&& c.MigrationPathId == MigrationPathId.Enterprise2020AnnualToCurrent
&& c.IsActive == false));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants