Skip to content

[PM-36949] feat: Add OrganizationPlanMigrationCohort and Assignment tables with bare repositories#7644

Open
amorask-bitwarden wants to merge 13 commits into
mainfrom
billing/PM-36949/add-organizationplanmigrationcohort-tables
Open

[PM-36949] feat: Add OrganizationPlanMigrationCohort and Assignment tables with bare repositories#7644
amorask-bitwarden wants to merge 13 commits into
mainfrom
billing/PM-36949/add-organizationplanmigrationcohort-tables

Conversation

@amorask-bitwarden
Copy link
Copy Markdown
Contributor

@amorask-bitwarden amorask-bitwarden commented May 15, 2026

🎟️ Tracking

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

📔 Objective

Foundational schema and repository layer for the business plan price migration program — the first implementation ticket under epic PM-35215. Lands the database tables, entities, value object, and bare CRUD repositories that ~12 sibling tickets consume. Scope is deliberately narrow; bulk CSV import, list-with-counts aggregates, the scheduler refactor, the churn-mitigation runtime, and all Admin Portal endpoints are owned by downstream tickets.

What's in

  • Two new tables on MSSQL (SSDT + Migrator script + 9 stored procedures) and EF Core (MySQL / Postgres / Sqlite) — OrganizationPlanMigrationCohort and OrganizationPlanMigrationCohortAssignment. Includes composite index (CohortId, ScheduledAt, MigratedAt) on the assignment table to support the downstream Cohort Management aggregate without a follow-up ALTER.
  • Cohort entity, Assignment entity, MigrationPath value object + MigrationPaths registry, and MigrationPathId byte-backed enum in Bit.Core.Billing.Organizations.PlanMigration.
  • Dapper repository implementations subclassing Repository<T, TId>; assignment repo additionally exposes GetByOrganizationIdAsync.
  • EF Core wrapper models + IEntityTypeConfiguration<T> + repository implementations; provider migrations registered for MySQL, Postgres, and Sqlite.
  • _Update stored procedures use the "accept-but-don't-assign" pattern for immutable columns (cohort: CreatedAt; assignment: OrganizationId, CohortId, CreatedAt). EF repositories mirror this via ReplaceAsync overrides (IsModified = false on the immutable properties), matching the DeviceRepository.ReplaceAsync idiom.
  • DI registrations for both ORMs.
  • Snapshot tests locking MigrationPathId byte values and each registered path's FromPlan / ToPlan — these are immortal once shipped.
  • Integration tests covering CRUD, the immutability invariants, the unique constraints on cohort Name and assignment OrganizationId, and cascade-delete from both Organization and Cohort.

What's intentionally out of scope

Tracked by sibling tickets under PM-35215. Not in this PR:

  • Bulk CSV import (TVP + MERGE proc) — PM-36963
  • List-with-counts aggregate for Cohort Management — PM-36951
  • PriceIncreaseScheduler refactor — PM-37064
  • UpcomingInvoiceHandler business-plan branch — PM-37068
  • Churn-mitigation runtime — PM-37170
  • All Admin Portal controllers, endpoints, and pages

… types

Add the foundation for cohort-based plan migrations:
- Two tables: OrganizationPlanMigrationCohort and OrganizationPlanMigrationCohortAssignment
- Two views and nine stored procedures (four CRUD on cohort, four CRUD plus
  ReadByOrganizationId on assignment)
- Single Migrator script for MSSQL deployment
- Core entities, MigrationPath value object and its registry, and bare repository
  interfaces under Bit.Core.Billing.Organizations.PlanMigration

The cohort table holds the human-managed metadata (name, discount coupons,
MigrationPathId byte) and the assignment table records each organization's
position in the migration lifecycle (scheduled, migrated, churn-mitigated).
Both Update SPs follow the accept-but-don't-assign pattern: immutable columns
(OrganizationId, CohortId, CreatedAt) are parameters but not SET clauses.
OrganizationPlanMigrationCohortRepository inherits the base Repository<T, TId>
CRUD methods unchanged. OrganizationPlanMigrationCohortAssignmentRepository
also relies on the base for CRUD and adds GetByOrganizationIdAsync which
returns at most one row (the UNIQUE constraint on OrganizationId at the
database layer guarantees this).
…rations for plan migration cohort tables

- EF models wrap the Core entities; the assignment model exposes nav properties
  for Organization and Cohort so the FK + cascade-delete is inferred by EF.
- EntityTypeConfiguration classes pin ID generation to application code
  (ValueGeneratedNever) and declare the UNIQUE indexes plus the composite
  (CohortId, ScheduledAt, MigratedAt) index.
- Repositories follow the OrganizationInstallationRepository template; the
  assignment repo adds GetByOrganizationIdAsync to mirror the SP exposed on
  the MSSQL side.
- DatabaseContext gets two DbSet properties; auto-discovery picks up the
  configuration classes.
- Generated migrations for MySQL, Postgres, and SQLite create matching schemas;
  EF truncates FK and index names on providers with 64-char identifier limits,
  which is consistent with the rest of the codebase.
…ories

Register both Dapper and EF Core repositories in their respective service
collection extensions, following the existing AddSingleton convention in
these files.

Add tests:
- MigrationPathIdsSnapshotTests guards the immortal byte IDs that downstream
  code pins on. The class- and method-level comments document why these
  values can never be renumbered.
- MigrationPathTests covers the FromId round-trip and the null-on-unknown
  behavior the registry promises to callers.
- OrganizationPlanMigrationCohortRepositoryTests exercises CRUD, the UNIQUE
  Name constraint, and verifies that ReplaceAsync ignores CreatedAt
  mutations (per the accept-but-don't-assign Update SP).
- OrganizationPlanMigrationCohortAssignmentRepositoryTests exercises CRUD,
  GetByOrganizationIdAsync, the UNIQUE OrganizationId constraint,
  cascade-delete from both Organization and Cohort, and verifies that
  ReplaceAsync ignores OrganizationId, CohortId, and CreatedAt mutations.
Replace the byte Id with a byte-backed MigrationPathId enum and replace
the string FromPlan/ToPlan fields with PlanType. Persistence is
unchanged -- EF normalises enum-backed properties to their underlying
type in the model snapshot, and Dapper handles enum-to-byte parameter
mapping automatically.
…mmutability parity

The Dapper _Update SPs accept-but-don't-assign certain columns (CreatedAt
on cohort; OrganizationId, CohortId, and CreatedAt on assignment), but
the base EF Repository<T,TEntity,Guid>.ReplaceAsync uses SetValues which
writes every scalar. Override on both repos and mark the immutable
properties as IsModified = false so MySQL/Postgres/Sqlite match MSSQL
behavior. Mirrors the existing DeviceRepository.ReplaceAsync pattern.
Add [MaxLength] attributes to the three cohort string properties so the
EF providers (MySQL/Postgres/Sqlite) enforce the same limits as MSSQL,
where the columns were already NVARCHAR-capped. Widen Name from 64 to
255 chars across MSSQL DDL, both _Create/_Update SP signatures, the
Migrator script, the entity, and all three regenerated EF migrations.
Coupon codes stay at 64 (Stripe IDs are short).
The snapshot test class doc says "byte N means a specific FromPlan ->
ToPlan transition forever", but only the byte value was being asserted.
A silent refactor of the registry's PlanType references would not have
been caught. Add per-path FromPlan/ToPlan assertions to close the gap.
@amorask-bitwarden amorask-bitwarden added the ai-review Request a Claude code review label May 15, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the foundational schema and repository layer for the business plan price migration program (PM-36949). The PR adds two tables (OrganizationPlanMigrationCohort and OrganizationPlanMigrationCohortAssignment) with MSSQL stored procedures, EF Core configurations and models, Dapper and EF repositories, provider migrations for MySQL/Postgres/SQLite, DI wiring, snapshot tests, and integration tests. Scope is appropriately narrow with downstream consumers (bulk CSV import, aggregates, scheduler refactor, churn-mitigation runtime, Admin Portal endpoints) explicitly deferred to sibling tickets under epic PM-35215.

The implementation follows established codebase patterns: internal set; for immutable fields mirrors Device.cs/Folder.cs; EF model inheritance with AutoMapper ReverseMap() matches the Billing convention; ReplaceAsync overrides using IsModified = false for immutable columns match the explicitly-cited DeviceRepository.ReplaceAsync idiom; DI registrations use AddSingleton consistent with neighboring entries in both DapperServiceCollectionExtensions and EntityFrameworkServiceCollectionExtensions. Schema parity across MSSQL/MySQL/Postgres/SQLite is consistent including the composite (CohortId, ScheduledAt, MigratedAt) index, both ON DELETE CASCADE FKs, and uniqueness on Cohort.Name and Assignment.OrganizationId.

The snapshot tests appropriately lock the byte values of MigrationPathId enum members and the FromPlan/ToPlan for each registered path — the right immortality guard for values persisted into customer records and referenced downstream by Stripe coupon decisions. Integration tests cover CRUD, both unique-constraint violations, the "accept-but-don't-assign" immutability invariants for CreatedAt/OrganizationId/CohortId on the Update SP, and cascade deletion from both parent tables.

No findings.

Postgres timestamp and MySQL datetime(6) store microsecond precision (6
fractional digits), but .NET DateTime is 100ns ticks (7 digits). Exact
Assert.Equal fails by a single tick on round-trip. Switch the three
DateTime comparisons in ReplaceAsync_UpdatesMutableColumns_AndIgnoresImmutableOnes
to LaxDateTimeComparer.Default -- the same 2ms-tolerance comparer used
by SendRepositoryTests and InstallationRepositoryTests for the same
precision issue.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 96.25000% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.31%. Comparing base (20c9134) to head (11e6da7).

Files with missing lines Patch % Lines
...nizationPlanMigrationCohortAssignmentRepository.cs 88.46% 2 Missing and 1 partial ⚠️
...ories/OrganizationPlanMigrationCohortRepository.cs 81.25% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7644      +/-   ##
==========================================
+ Coverage   59.84%   64.31%   +4.47%     
==========================================
  Files        2121     2133      +12     
  Lines       93460    93620     +160     
  Branches     8291     8296       +5     
==========================================
+ Hits        55931    60215    +4284     
+ Misses      35548    31335    -4213     
- Partials     1981     2070      +89     

☔ 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.

@amorask-bitwarden amorask-bitwarden marked this pull request as ready for review May 15, 2026 17:28
@amorask-bitwarden amorask-bitwarden requested review from a team as code owners May 15, 2026 17:28
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@mkincaid-bw mkincaid-bw left a comment

Choose a reason for hiding this comment

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

Just some minor naming issues.

Also, total nitpick but there should be no space between the type name and its size (e.g., DATETIME2(7) not DATETIME2 (7)
https://contributing.bitwarden.com/contributing/code-style/sql/#column-definitions

[ProactiveDiscountCouponCode] NVARCHAR (64) NULL,
[ChurnDiscountCouponCode] NVARCHAR (64) NULL,
[IsActive] BIT NOT NULL CONSTRAINT [DF_OrganizationPlanMigrationCohort_IsActive] DEFAULT (0),
[CreatedAt] DATETIME2 (7) NOT NULL,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should be named CreationDate

Comment on lines +5 to +8
[ScheduledAt] DATETIME2 (7) NULL,
[MigratedAt] DATETIME2 (7) NULL,
[ChurnDiscountAppliedAt] DATETIME2 (7) NULL,
[CreatedAt] DATETIME2 (7) NOT NULL,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Datetime column names should end with Date, not At

@ProactiveDiscountCouponCode NVARCHAR(64),
@ChurnDiscountCouponCode NVARCHAR(64),
@IsActive BIT,
@CreatedAt DATETIME2 (7),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same with the column names, Datetime parameter names should end with Date, not At

@ProactiveDiscountCouponCode NVARCHAR(64),
@ChurnDiscountCouponCode NVARCHAR(64),
@IsActive BIT,
@CreatedAt DATETIME2 (7),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue (Date vs At)

Comment on lines +5 to +8
@ScheduledAt DATETIME2 (7),
@MigratedAt DATETIME2 (7),
@ChurnDiscountAppliedAt DATETIME2 (7),
@CreatedAt DATETIME2 (7),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue (Date vs At)

Comment on lines +5 to +8
@ScheduledAt DATETIME2 (7),
@MigratedAt DATETIME2 (7),
@ChurnDiscountAppliedAt DATETIME2 (7),
@CreatedAt DATETIME2 (7),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue (Date vs At)

Copy link
Copy Markdown
Collaborator

@sbrown-livefront sbrown-livefront left a comment

Choose a reason for hiding this comment

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

✨ Clean. Just a question.

Comment thread .gitignore
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.

4 participants