1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[AC-2551] Consolidated Billing Migration (#4616)

* Move existing Billing SQL files into dbo folder

I noticed that every other team had a nested dbo folder under their team folder while Billing did not. This change replicates that.

* Add SQL files for ClientOrganizationMigrationRecord table

* Add SQL Server migration for ClientOrganizationMigrationRecord table

* Add ClientOrganizationMigrationRecord entity and repository interface

* Add ClientOrganizationMigrationRecord Dapper repository

* Add ClientOrganizationMigrationRecord EF repository

* Add EF migrations for ClientOrganizationMigrationRecord table

* Implement migration process

* Wire up new Admin tool to migrate providers

* Run dotnet format

* Updated coupon and credit application per product request

* AC-3057-3058: Fix expiration date and enabled from webhook processing

* Run dotnet format

* AC-3059: Fix assigned seats during migration

* Updated AllocatedSeats in the case plan already exists

* Update migration scripts to reflect current date
This commit is contained in:
Alex Morask 2024-10-04 10:55:00 -04:00 committed by GitHub
parent 738febf031
commit 0496085c39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 10418 additions and 3 deletions

View File

@ -0,0 +1,83 @@
using Bit.Admin.Billing.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Migration.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("migrate-providers")]
[SelfHosted(NotSelfHostedOnly = true)]
public class MigrateProvidersController(
IProviderMigrator providerMigrator) : Controller
{
[HttpGet]
[RequirePermission(Permission.Tools_MigrateProviders)]
public IActionResult Index()
{
return View(new MigrateProvidersRequestModel());
}
[HttpPost]
[RequirePermission(Permission.Tools_MigrateProviders)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return RedirectToAction("Index");
}
foreach (var providerId in providerIds)
{
await providerMigrator.Migrate(providerId);
}
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
}
[HttpGet("results")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return View(Array.Empty<ProviderMigrationResult>());
}
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
return View(results);
}
[HttpGet("results/{providerId:guid}")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
{
var result = await providerMigrator.GetResult(providerId);
if (result == null)
{
return RedirectToAction("Index");
}
return View(result);
}
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
? text.Split(
["\r\n", "\r", "\n"],
StringSplitOptions.TrimEntries
)
.Select(id => new Guid(id))
.ToList()
: [];
}

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models;
public class MigrateProvidersRequestModel
{
[Required]
[Display(Name = "Provider IDs")]
public string ProviderIds { get; set; }
}

View File

@ -0,0 +1,39 @@
@using System.Text.Json
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Migration Details: @Model.ProviderName</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
<dt class="col-sm-4 col-lg-3">Result</dt>
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
</dl>
<h3>Client Organizations</h3>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
<th>Previous State</th>
</tr>
</thead>
<tbody>
@foreach (var clientResult in Model.Clients)
{
<tr>
<td>@clientResult.OrganizationId</td>
<td>@clientResult.OrganizationName</td>
<td>@clientResult.Result</td>
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
</tr>
}
</tbody>
</table>
</div>

View File

@ -0,0 +1,46 @@
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
@{
ViewData["Title"] = "Migrate Providers";
}
<h1>Migrate Providers</h1>
<h2>Bulk Consolidated Billing Migration Tool</h2>
<section>
<p>
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
</p>
<p class="alert alert-warning">
Updates made through this tool are irreversible without manual intervention.
</p>
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
<div class="card">
<div class="card-body">
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
174e82fc-70c3-448d-9fe7-00bad2a3ab00
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
</div>
</div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="form-group">
<input type="submit" value="Run" class="btn btn-primary mb-2"/>
</div>
</form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="form-group">
<input type="submit" value="See Previous Results" class="btn btn-primary mb-2"/>
</div>
</form>
</section>

View File

@ -0,0 +1,28 @@
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[]
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Results</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
</tr>
</thead>
<tbody>
@foreach (var result in Model)
{
<tr>
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
<td>@result.ProviderName</td>
<td>@result.Result</td>
</tr>
}
</tbody>
</table>
</div>

View File

@ -48,5 +48,6 @@ public enum Permission
Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction,
Tools_ProcessStripeEvents
Tools_ProcessStripeEvents,
Tools_MigrateProviders
}

View File

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Migration;
#if !OSS
using Bit.Commercial.Core.Utilities;
@ -88,8 +89,10 @@ public class Startup
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>();
services.AddDistributedCache(globalSettings);
services.AddBillingOperations();
services.AddHttpClient();
services.AddProviderMigration();
#if OSS
services.AddOosServices();

View File

@ -163,6 +163,7 @@ public static class RolePermissionMapping
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction,
Permission.Tools_ProcessStripeEvents,
Permission.Tools_MigrateProviders
}
},
{ "sales", new List<Permission>

View File

@ -15,6 +15,7 @@
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin ||
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
@ -114,6 +115,12 @@
Process Stripe Events
</a>
}
@if (canMigrateProviders)
{
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
Migrate Providers
</a>
}
</div>
</li>
}

View File

@ -32,12 +32,14 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
const string providerMigrationCancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
if (!subCanceled)
{
return;
}
if (organizationId.HasValue)
if (organizationId.HasValue && subscription is not { CancellationDetails.Comment: providerMigrationCancellationComment })
{
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
}

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Entities;
public class ClientOrganizationMigrationRecord : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public Guid ProviderId { get; set; }
public PlanType PlanType { get; set; }
public int Seats { get; set; }
public short? MaxStorageGb { get; set; }
[MaxLength(50)] public string GatewayCustomerId { get; set; } = null!;
[MaxLength(50)] public string GatewaySubscriptionId { get; set; } = null!;
public DateTime? ExpirationDate { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public OrganizationStatusType Status { get; set; }
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}

View File

@ -0,0 +1,23 @@
namespace Bit.Core.Billing.Migration.Models;
public enum ClientMigrationProgress
{
Started = 1,
MigrationRecordCreated = 2,
SubscriptionEnded = 3,
Completed = 4,
Reversing = 5,
ResetOrganization = 6,
RecreatedSubscription = 7,
RemovedMigrationRecord = 8,
Reversed = 9
}
public class ClientMigrationTracker
{
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
public string OrganizationName { get; set; }
public ClientMigrationProgress Progress { get; set; } = ClientMigrationProgress.Started;
}

View File

@ -0,0 +1,45 @@
using Bit.Core.Billing.Entities;
namespace Bit.Core.Billing.Migration.Models;
public class ProviderMigrationResult
{
public Guid ProviderId { get; set; }
public string ProviderName { get; set; }
public string Result { get; set; }
public List<ClientMigrationResult> Clients { get; set; }
}
public class ClientMigrationResult
{
public Guid OrganizationId { get; set; }
public string OrganizationName { get; set; }
public string Result { get; set; }
public ClientPreviousState PreviousState { get; set; }
}
public class ClientPreviousState
{
public ClientPreviousState() { }
public ClientPreviousState(ClientOrganizationMigrationRecord migrationRecord)
{
PlanType = migrationRecord.PlanType.ToString();
Seats = migrationRecord.Seats;
MaxStorageGb = migrationRecord.MaxStorageGb;
GatewayCustomerId = migrationRecord.GatewayCustomerId;
GatewaySubscriptionId = migrationRecord.GatewaySubscriptionId;
ExpirationDate = migrationRecord.ExpirationDate;
MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats;
Status = migrationRecord.Status.ToString();
}
public string PlanType { get; set; }
public int Seats { get; set; }
public short? MaxStorageGb { get; set; }
public string GatewayCustomerId { get; set; } = null!;
public string GatewaySubscriptionId { get; set; } = null!;
public DateTime? ExpirationDate { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public string Status { get; set; }
}

View File

@ -0,0 +1,25 @@
namespace Bit.Core.Billing.Migration.Models;
public enum ProviderMigrationProgress
{
Started = 1,
ClientsMigrated = 2,
TeamsPlanConfigured = 3,
EnterprisePlanConfigured = 4,
CustomerSetup = 5,
SubscriptionSetup = 6,
CreditApplied = 7,
Completed = 8,
Reversing = 9,
ReversedClientMigrations = 10,
RemovedProviderPlans = 11
}
public class ProviderMigrationTracker
{
public Guid ProviderId { get; set; }
public string ProviderName { get; set; }
public List<Guid> OrganizationIds { get; set; }
public ProviderMigrationProgress Progress { get; set; } = ProviderMigrationProgress.Started;
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Billing.Migration.Services;
using Bit.Core.Billing.Migration.Services.Implementations;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Migration;
public static class ServiceCollectionExtensions
{
public static void AddProviderMigration(this IServiceCollection services)
{
services.AddTransient<IMigrationTrackerCache, MigrationTrackerDistributedCache>();
services.AddTransient<IOrganizationMigrator, OrganizationMigrator>();
services.AddTransient<IProviderMigrator, ProviderMigrator>();
}
}

View File

@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Migration.Models;
namespace Bit.Core.Billing.Migration.Services;
public interface IMigrationTrackerCache
{
Task StartTracker(Provider provider);
Task SetOrganizationIds(Guid providerId, IEnumerable<Guid> organizationIds);
Task<ProviderMigrationTracker> GetTracker(Guid providerId);
Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status);
Task StartTracker(Guid providerId, Organization organization);
Task<ClientMigrationTracker> GetTracker(Guid providerId, Guid organizationId);
Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.Billing.Migration.Services;
public interface IOrganizationMigrator
{
Task Migrate(Guid providerId, Organization organization);
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Billing.Migration.Models;
namespace Bit.Core.Billing.Migration.Services;
public interface IProviderMigrator
{
Task Migrate(Guid providerId);
Task<ProviderMigrationResult> GetResult(Guid providerId);
}

View File

@ -0,0 +1,107 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Migration.Models;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Migration.Services.Implementations;
public class MigrationTrackerDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache) : IMigrationTrackerCache
{
public async Task StartTracker(Provider provider) =>
await SetAsync(new ProviderMigrationTracker
{
ProviderId = provider.Id,
ProviderName = provider.Name
});
public async Task SetOrganizationIds(Guid providerId, IEnumerable<Guid> organizationIds)
{
var tracker = await GetAsync(providerId);
tracker.OrganizationIds = organizationIds.ToList();
await SetAsync(tracker);
}
public Task<ProviderMigrationTracker> GetTracker(Guid providerId) => GetAsync(providerId);
public async Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status)
{
var tracker = await GetAsync(providerId);
tracker.Progress = status;
await SetAsync(tracker);
}
public async Task StartTracker(Guid providerId, Organization organization) =>
await SetAsync(new ClientMigrationTracker
{
ProviderId = providerId,
OrganizationId = organization.Id,
OrganizationName = organization.Name
});
public Task<ClientMigrationTracker> GetTracker(Guid providerId, Guid organizationId) =>
GetAsync(providerId, organizationId);
public async Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status)
{
var tracker = await GetAsync(providerId, organizationId);
tracker.Progress = status;
await SetAsync(tracker);
}
private static string GetProviderCacheKey(Guid providerId) => $"provider_{providerId}_migration";
private static string GetClientCacheKey(Guid providerId, Guid clientId) =>
$"provider_{providerId}_client_{clientId}_migration";
private async Task<ProviderMigrationTracker> GetAsync(Guid providerId)
{
var cacheKey = GetProviderCacheKey(providerId);
var json = await distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<ProviderMigrationTracker>(json);
}
private async Task<ClientMigrationTracker> GetAsync(Guid providerId, Guid organizationId)
{
var cacheKey = GetClientCacheKey(providerId, organizationId);
var json = await distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<ClientMigrationTracker>(json);
}
private async Task SetAsync(ProviderMigrationTracker tracker)
{
var cacheKey = GetProviderCacheKey(tracker.ProviderId);
var json = JsonSerializer.Serialize(tracker);
await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30)
});
}
private async Task SetAsync(ClientMigrationTracker tracker)
{
var cacheKey = GetClientCacheKey(tracker.ProviderId, tracker.OrganizationId);
var json = JsonSerializer.Serialize(tracker);
await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30)
});
}
}

View File

@ -0,0 +1,326 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Core.Billing.Migration.Services.Implementations;
public class OrganizationMigrator(
IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository,
ILogger<OrganizationMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IStripeAdapter stripeAdapter) : IOrganizationMigrator
{
private const string _cancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
public async Task Migrate(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Starting migration for organization ({OrganizationID})", organization.Id);
await migrationTrackerCache.StartTracker(providerId, organization);
await CreateMigrationRecordAsync(providerId, organization);
await CancelSubscriptionAsync(providerId, organization);
await UpdateOrganizationAsync(providerId, organization);
}
#region Steps
private async Task CreateMigrationRecordAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Creating ClientOrganizationMigrationRecord for organization ({OrganizationID})", organization.Id);
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord != null)
{
logger.LogInformation(
"CB: ClientOrganizationMigrationRecord already exists for organization ({OrganizationID}), deleting record",
organization.Id);
await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord);
}
await clientOrganizationMigrationRecordRepository.CreateAsync(new ClientOrganizationMigrationRecord
{
OrganizationId = organization.Id,
ProviderId = providerId,
PlanType = organization.PlanType,
Seats = organization.Seats ?? 0,
MaxStorageGb = organization.MaxStorageGb,
GatewayCustomerId = organization.GatewayCustomerId!,
GatewaySubscriptionId = organization.GatewaySubscriptionId!,
ExpirationDate = organization.ExpirationDate,
MaxAutoscaleSeats = organization.MaxAutoscaleSeats,
Status = organization.Status
});
logger.LogInformation("CB: Created migration record for organization ({OrganizationID})", organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.MigrationRecordCreated);
}
private async Task CancelSubscriptionAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Cancelling subscription for organization ({OrganizationID})", organization.Id);
var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
if (subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.PastDue or
StripeConstants.SubscriptionStatus.Trialing
})
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = _cancellationComment
},
InvoiceNow = true,
Prorate = true,
Expand = ["latest_invoice", "test_clock"]
});
logger.LogInformation("CB: Cancelled subscription for organization ({OrganizationID})", organization.Id);
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var trialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!trialing && subscription is { Status: StripeConstants.SubscriptionStatus.Canceled, CancellationDetails.Comment: _cancellationComment })
{
var latestInvoice = subscription.LatestInvoice;
if (latestInvoice.Status == "draft")
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(latestInvoice.Id,
new InvoiceFinalizeOptions { AutoAdvance = true });
logger.LogInformation("CB: Finalized prorated invoice for organization ({OrganizationID})", organization.Id);
}
}
}
else
{
logger.LogInformation(
"CB: Did not need to cancel subscription for organization ({OrganizationID}) as it was inactive",
organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.SubscriptionEnded);
}
private async Task UpdateOrganizationAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Bringing organization ({OrganizationID}) under provider management",
organization.Id);
var plan = StaticStore.GetPlan(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Brought organization ({OrganizationID}) under provider management",
organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.Completed);
}
#endregion
#region Reverse
private async Task RemoveMigrationRecordAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Removing migration record for organization ({OrganizationID})", organization.Id);
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord != null)
{
await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord);
logger.LogInformation(
"CB: Removed migration record for organization ({OrganizationID})",
organization.Id);
}
else
{
logger.LogInformation("CB: Did not remove migration record for organization ({OrganizationID}) as it does not exist", organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, ClientMigrationProgress.Reversed);
}
private async Task RecreateSubscriptionAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Recreating subscription for organization ({OrganizationID})", organization.Id);
if (!string.IsNullOrEmpty(organization.GatewaySubscriptionId))
{
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogError(
"CB: Cannot recreate subscription for organization ({OrganizationID}) as it does not have a Stripe customer",
organization.Id);
throw new Exception();
}
var customer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId,
new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] });
var collectionMethod =
customer.DefaultSource != null ||
customer.InvoiceSettings?.DefaultPaymentMethod != null ||
customer.Metadata.ContainsKey(Utilities.BraintreeCustomerIdKey)
? StripeConstants.CollectionMethod.ChargeAutomatically
: StripeConstants.CollectionMethod.SendInvoice;
var plan = StaticStore.GetPlan(organization.PlanType);
var items = new List<SubscriptionItemOptions>
{
new ()
{
Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = organization.Seats
}
};
if (organization.MaxStorageGb.HasValue && plan.PasswordManager.BaseStorageGb.HasValue && organization.MaxStorageGb.Value > plan.PasswordManager.BaseStorageGb.Value)
{
var additionalStorage = organization.MaxStorageGb.Value - plan.PasswordManager.BaseStorageGb.Value;
items.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeStoragePlanId,
Quantity = additionalStorage
});
}
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
Customer = customer.Id,
CollectionMethod = collectionMethod,
DaysUntilDue = collectionMethod == StripeConstants.CollectionMethod.SendInvoice ? 30 : null,
Items = items,
Metadata = new Dictionary<string, string>
{
[organization.GatewayIdField()] = organization.Id.ToString()
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = plan.TrialPeriodDays
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Recreated subscription for organization ({OrganizationID})", organization.Id);
}
else
{
logger.LogInformation(
"CB: Did not recreate subscription for organization ({OrganizationID}) as it already exists",
organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.RecreatedSubscription);
}
private async Task ReverseOrganizationUpdateAsync(Guid providerId, Organization organization)
{
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord == null)
{
logger.LogError(
"CB: Cannot reverse migration for organization ({OrganizationID}) as it does not have a migration record",
organization.Id);
throw new Exception();
}
var plan = StaticStore.GetPlan(migrationRecord.PlanType);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = migrationRecord.MaxStorageGb;
organization.ExpirationDate = migrationRecord.ExpirationDate;
organization.MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats;
organization.Status = migrationRecord.Status;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Reversed organization ({OrganizationID}) updates",
organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.ResetOrganization);
}
#endregion
#region Shared
private static void ResetOrganizationPlan(Organization organization, Plan plan)
{
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
organization.UseTotp = plan.HasTotp;
organization.Use2fa = plan.Has2fa;
organization.UseApi = plan.HasApi;
organization.UseResetPassword = plan.HasResetPassword;
organization.SelfHost = plan.HasSelfHost;
organization.UsersGetPremium = plan.UsersGetPremium;
organization.UseCustomPermissions = plan.HasCustomPermissions;
organization.UseScim = plan.HasScim;
organization.UseKeyConnector = plan.HasKeyConnector;
}
#endregion
}

View File

@ -0,0 +1,385 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Migration.Services.Implementations;
public class ProviderMigrator(
IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository,
IOrganizationMigrator organizationMigrator,
ILogger<ProviderMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderPlanRepository providerPlanRepository,
IStripeAdapter stripeAdapter) : IProviderMigrator
{
public async Task Migrate(Guid providerId)
{
var provider = await GetProviderAsync(providerId);
if (provider == null)
{
return;
}
logger.LogInformation("CB: Starting migration for provider ({ProviderID})", providerId);
await migrationTrackerCache.StartTracker(provider);
await MigrateClientsAsync(providerId);
await ConfigureTeamsPlanAsync(providerId);
await ConfigureEnterprisePlanAsync(providerId);
await SetupCustomerAsync(provider);
await SetupSubscriptionAsync(provider);
await ApplyCreditAsync(provider);
await UpdateProviderAsync(provider);
}
public async Task<ProviderMigrationResult> GetResult(Guid providerId)
{
var providerTracker = await migrationTrackerCache.GetTracker(providerId);
if (providerTracker == null)
{
return null;
}
var clientTrackers = await Task.WhenAll(providerTracker.OrganizationIds.Select(organizationId =>
migrationTrackerCache.GetTracker(providerId, organizationId)));
var migrationRecordLookup = new Dictionary<Guid, ClientOrganizationMigrationRecord>();
foreach (var clientTracker in clientTrackers)
{
var migrationRecord =
await clientOrganizationMigrationRecordRepository.GetByOrganizationId(clientTracker.OrganizationId);
migrationRecordLookup.Add(clientTracker.OrganizationId, migrationRecord);
}
return new ProviderMigrationResult
{
ProviderId = providerTracker.ProviderId,
ProviderName = providerTracker.ProviderName,
Result = providerTracker.Progress.ToString(),
Clients = clientTrackers.Select(tracker =>
{
var foundMigrationRecord = migrationRecordLookup.TryGetValue(tracker.OrganizationId, out var migrationRecord);
return new ClientMigrationResult
{
OrganizationId = tracker.OrganizationId,
OrganizationName = tracker.OrganizationName,
Result = tracker.Progress.ToString(),
PreviousState = foundMigrationRecord ? new ClientPreviousState(migrationRecord) : null
};
}).ToList(),
};
}
#region Steps
private async Task MigrateClientsAsync(Guid providerId)
{
logger.LogInformation("CB: Migrating clients for provider ({ProviderID})", providerId);
var organizations = await GetEnabledClientsAsync(providerId);
var organizationIds = organizations.Select(organization => organization.Id);
await migrationTrackerCache.SetOrganizationIds(providerId, organizationIds);
foreach (var organization in organizations)
{
var tracker = await migrationTrackerCache.GetTracker(providerId, organization.Id);
if (tracker is not { Progress: ClientMigrationProgress.Completed })
{
await organizationMigrator.Migrate(providerId, organization);
}
}
logger.LogInformation("CB: Migrated clients for provider ({ProviderID})", providerId);
await migrationTrackerCache.UpdateTrackingStatus(providerId,
ProviderMigrationProgress.ClientsMigrated);
}
private async Task ConfigureTeamsPlanAsync(Guid providerId)
{
logger.LogInformation("CB: Configuring Teams plan for provider ({ProviderID})", providerId);
var organizations = await GetEnabledClientsAsync(providerId);
var teamsSeats = organizations
.Where(IsTeams)
.Sum(client => client.Seats) ?? 0;
var teamsProviderPlan = (await providerPlanRepository.GetByProviderId(providerId))
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null)
{
await providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = providerId,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = teamsSeats,
PurchasedSeats = 0,
AllocatedSeats = teamsSeats
});
logger.LogInformation("CB: Created Teams plan for provider ({ProviderID}) with a seat minimum of {Seats}",
providerId, teamsSeats);
}
else
{
logger.LogInformation("CB: Teams plan already exists for provider ({ProviderID}), updating seat minimum", providerId);
teamsProviderPlan.SeatMinimum = teamsSeats;
teamsProviderPlan.AllocatedSeats = teamsSeats;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
logger.LogInformation("CB: Updated Teams plan for provider ({ProviderID}) to seat minimum of {Seats}",
providerId, teamsProviderPlan.SeatMinimum);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.TeamsPlanConfigured);
}
private async Task ConfigureEnterprisePlanAsync(Guid providerId)
{
logger.LogInformation("CB: Configuring Enterprise plan for provider ({ProviderID})", providerId);
var organizations = await GetEnabledClientsAsync(providerId);
var enterpriseSeats = organizations
.Where(IsEnterprise)
.Sum(client => client.Seats) ?? 0;
var enterpriseProviderPlan = (await providerPlanRepository.GetByProviderId(providerId))
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null)
{
await providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = providerId,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = enterpriseSeats,
PurchasedSeats = 0,
AllocatedSeats = enterpriseSeats
});
logger.LogInformation("CB: Created Enterprise plan for provider ({ProviderID}) with a seat minimum of {Seats}",
providerId, enterpriseSeats);
}
else
{
logger.LogInformation("CB: Enterprise plan already exists for provider ({ProviderID}), updating seat minimum", providerId);
enterpriseProviderPlan.SeatMinimum = enterpriseSeats;
enterpriseProviderPlan.AllocatedSeats = enterpriseSeats;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
logger.LogInformation("CB: Updated Enterprise plan for provider ({ProviderID}) to seat minimum of {Seats}",
providerId, enterpriseProviderPlan.SeatMinimum);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.EnterprisePlanConfigured);
}
private async Task SetupCustomerAsync(Provider provider)
{
if (string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var organizations = await GetEnabledClientsAsync(provider.Id);
var sampleOrganization = organizations.FirstOrDefault(organization => !string.IsNullOrEmpty(organization.GatewayCustomerId));
if (sampleOrganization == null)
{
logger.LogInformation(
"CB: Could not find sample organization for provider ({ProviderID}) that has a Stripe customer",
provider.Id);
return;
}
var taxInfo = await paymentService.GetTaxInfoAsync(sampleOrganization);
var customer = await providerBillingService.SetupCustomer(provider, taxInfo);
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Coupon = StripeConstants.CouponIDs.MSPDiscount35
});
provider.GatewayCustomerId = customer.Id;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Setup Stripe customer for provider ({ProviderID})", provider.Id);
}
else
{
logger.LogInformation("CB: Stripe customer already exists for provider ({ProviderID})", provider.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CustomerSetup);
}
private async Task SetupSubscriptionAsync(Provider provider)
{
if (string.IsNullOrEmpty(provider.GatewaySubscriptionId))
{
if (!string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var subscription = await providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Setup Stripe subscription for provider ({ProviderID})", provider.Id);
}
else
{
logger.LogInformation(
"CB: Could not set up Stripe subscription for provider ({ProviderID}) with no Stripe customer",
provider.Id);
return;
}
}
else
{
logger.LogInformation("CB: Stripe subscription already exists for provider ({ProviderID})", provider.Id);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var enterpriseSeatMinimum = providerPlans
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly)?
.SeatMinimum ?? 0;
var teamsSeatMinimum = providerPlans
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
.SeatMinimum ?? 0;
await providerBillingService.UpdateSeatMinimums(provider, enterpriseSeatMinimum, teamsSeatMinimum);
logger.LogInformation(
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.SubscriptionSetup);
}
private async Task ApplyCreditAsync(Provider provider)
{
var organizations = await GetEnabledClientsAsync(provider.Id);
var organizationCustomers =
await Task.WhenAll(organizations.Select(organization => stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId)));
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
var legacyOrganizations = organizations.Where(organization =>
organization.PlanType is
PlanType.EnterpriseAnnually2020 or
PlanType.EnterpriseMonthly2020 or
PlanType.TeamsAnnually2020 or
PlanType.TeamsMonthly2020);
var legacyOrganizationCredit = legacyOrganizations.Sum(organization => organization.Seats ?? 0);
await stripeAdapter.CustomerUpdateAsync(provider.GatewayCustomerId, new CustomerUpdateOptions
{
Balance = organizationCancellationCredit + legacyOrganizationCredit
});
logger.LogInformation("CB: Applied {Credit} credit to provider ({ProviderID})", organizationCancellationCredit, provider.Id);
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CreditApplied);
}
private async Task UpdateProviderAsync(Provider provider)
{
provider.Status = ProviderStatusType.Billable;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Completed migration for provider ({ProviderID})", provider.Id);
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.Completed);
}
#endregion
#region Utilities
private async Task<List<Organization>> GetEnabledClientsAsync(Guid providerId)
{
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
return (await Task.WhenAll(providerOrganizations.Select(providerOrganization =>
organizationRepository.GetByIdAsync(providerOrganization.OrganizationId))))
.Where(organization => organization.Enabled)
.ToList();
}
private async Task<Provider> GetProviderAsync(Guid providerId)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it does not exist", providerId);
return null;
}
if (provider.Type != ProviderType.Msp)
{
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not an MSP", providerId);
return null;
}
if (provider.Status == ProviderStatusType.Created)
{
return provider;
}
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not in the 'Created' state", providerId);
return null;
}
private static bool IsEnterprise(Organization organization) => organization.Plan.Contains("Enterprise");
private static bool IsTeams(Organization organization) => organization.Plan.Contains("Teams");
#endregion
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Billing.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Billing.Repositories;
public interface IClientOrganizationMigrationRecordRepository : IRepository<ClientOrganizationMigrationRecord, Guid>
{
Task<ClientOrganizationMigrationRecord> GetByOrganizationId(Guid organizationId);
Task<ICollection<ClientOrganizationMigrationRecord>> GetByProviderId(Guid providerId);
}

View File

@ -0,0 +1,39 @@
using System.Data;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Repositories;
using Bit.Core.Settings;
using Bit.Infrastructure.Dapper.Repositories;
using Dapper;
using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.Billing.Repositories;
public class ClientOrganizationMigrationRecordRepository(
GlobalSettings globalSettings) : Repository<ClientOrganizationMigrationRecord, Guid>(
globalSettings.SqlServer.ConnectionString,
globalSettings.SqlServer.ReadOnlyConnectionString), IClientOrganizationMigrationRecordRepository
{
public async Task<ClientOrganizationMigrationRecord> GetByOrganizationId(Guid organizationId)
{
var sqlConnection = new SqlConnection(ConnectionString);
var results = await sqlConnection.QueryAsync<ClientOrganizationMigrationRecord>(
"[dbo].[ClientOrganizationMigrationRecord_ReadByOrganizationId]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
public async Task<ICollection<ClientOrganizationMigrationRecord>> GetByProviderId(Guid providerId)
{
var sqlConnection = new SqlConnection(ConnectionString);
var results = await sqlConnection.QueryAsync<ClientOrganizationMigrationRecord>(
"[dbo].[ClientOrganizationMigrationRecord_ReadByProviderId]",
new { ProviderId = providerId },
commandType: CommandType.StoredProcedure);
return results.ToArray();
}
}

View File

@ -56,6 +56,8 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<IProviderInvoiceItemRepository, ProviderInvoiceItemRepository>();
services.AddSingleton<INotificationRepository, NotificationRepository>();
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
if (selfHosted)
{

View File

@ -0,0 +1,21 @@
using Bit.Infrastructure.EntityFramework.Billing.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.Billing.Configurations;
public class ClientOrganizationMigrationRecordEntityTypeConfiguration : IEntityTypeConfiguration<ClientOrganizationMigrationRecord>
{
public void Configure(EntityTypeBuilder<ClientOrganizationMigrationRecord> builder)
{
builder
.Property(c => c.Id)
.ValueGeneratedNever();
builder
.HasIndex(migrationRecord => new { migrationRecord.ProviderId, migrationRecord.OrganizationId })
.IsUnique();
builder.ToTable(nameof(ClientOrganizationMigrationRecord));
}
}

View File

@ -0,0 +1,16 @@
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.Billing.Models;
public class ClientOrganizationMigrationRecord : Core.Billing.Entities.ClientOrganizationMigrationRecord
{
}
public class ClientOrganizationMigrationRecordProfile : Profile
{
public ClientOrganizationMigrationRecordProfile()
{
CreateMap<Core.Billing.Entities.ClientOrganizationMigrationRecord, ClientOrganizationMigrationRecord>().ReverseMap();
}
}

View File

@ -0,0 +1,47 @@
using AutoMapper;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using EFClientOrganizationMigrationRecord = Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord;
namespace Bit.Infrastructure.EntityFramework.Billing.Repositories;
public class ClientOrganizationMigrationRecordRepository(
IMapper mapper,
IServiceScopeFactory serviceScopeFactory)
: Repository<ClientOrganizationMigrationRecord, EFClientOrganizationMigrationRecord, Guid>(
serviceScopeFactory,
mapper,
context => context.ClientOrganizationMigrationRecords), IClientOrganizationMigrationRecordRepository
{
public async Task<ClientOrganizationMigrationRecord> GetByOrganizationId(Guid organizationId)
{
using var serviceScope = ServiceScopeFactory.CreateScope();
var databaseContext = GetDatabaseContext(serviceScope);
var query =
from clientOrganizationMigrationRecord in databaseContext.ClientOrganizationMigrationRecords
where clientOrganizationMigrationRecord.OrganizationId == organizationId
select clientOrganizationMigrationRecord;
return await query.FirstOrDefaultAsync();
}
public async Task<ICollection<ClientOrganizationMigrationRecord>> GetByProviderId(Guid providerId)
{
using var serviceScope = ServiceScopeFactory.CreateScope();
var databaseContext = GetDatabaseContext(serviceScope);
var query =
from clientOrganizationMigrationRecord in databaseContext.ClientOrganizationMigrationRecords
where clientOrganizationMigrationRecord.ProviderId == providerId
select clientOrganizationMigrationRecord;
return await query.ToArrayAsync();
}
}

View File

@ -93,6 +93,8 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<IProviderInvoiceItemRepository, ProviderInvoiceItemRepository>();
services.AddSingleton<INotificationRepository, NotificationRepository>();
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
if (selfHosted)
{

View File

@ -74,6 +74,7 @@ public class DatabaseContext : DbContext
public DbSet<ProviderInvoiceItem> ProviderInvoiceItems { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationStatus> NotificationStatuses { get; set; }
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,45 @@
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@ProviderId UNIQUEIDENTIFIER,
@PlanType TINYINT,
@Seats SMALLINT,
@MaxStorageGb SMALLINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ExpirationDate DATETIME2(7),
@MaxAutoscaleSeats INT,
@Status TINYINT
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[ClientOrganizationMigrationRecord]
(
[Id],
[OrganizationId],
[ProviderId],
[PlanType],
[Seats],
[MaxStorageGb],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ExpirationDate],
[MaxAutoscaleSeats],
[Status]
)
VALUES
(
@Id,
@OrganizationId,
@ProviderId,
@PlanType,
@Seats,
@MaxStorageGb,
@GatewayCustomerId,
@GatewaySubscriptionId,
@ExpirationDate,
@MaxAutoscaleSeats,
@Status
)
END

View File

@ -0,0 +1,12 @@
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_DeleteById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[ClientOrganizationMigrationRecord]
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecordView]
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecordView]
WHERE
[OrganizationId] = @OrganizationId
END

View File

@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadByProviderId]
@ProviderId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecordView]
WHERE
[ProviderId] = @ProviderId
END

View File

@ -0,0 +1,32 @@
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_Update]
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@ProviderId UNIQUEIDENTIFIER,
@PlanType TINYINT,
@Seats SMALLINT,
@MaxStorageGb SMALLINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ExpirationDate DATETIME2(7),
@MaxAutoscaleSeats INT,
@Status TINYINT
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[ClientOrganizationMigrationRecord]
SET
[OrganizationId] = @OrganizationId,
[ProviderId] = @ProviderId,
[PlanType] = @PlanType,
[Seats] = @Seats,
[MaxStorageGb] = @MaxStorageGb,
[GatewayCustomerId] = @GatewayCustomerId,
[GatewaySubscriptionId] = @GatewaySubscriptionId,
[ExpirationDate] = @ExpirationDate,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
[Status] = @Status
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,15 @@
CREATE TABLE [dbo].[ClientOrganizationMigrationRecord] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
[ProviderId] UNIQUEIDENTIFIER NOT NULL,
[PlanType] TINYINT NOT NULL,
[Seats] SMALLINT NOT NULL,
[MaxStorageGb] SMALLINT NULL,
[GatewayCustomerId] VARCHAR(50) NOT NULL,
[GatewaySubscriptionId] VARCHAR(50) NOT NULL,
[ExpirationDate] DATETIME2(7) NULL,
[MaxAutoscaleSeats] INT NULL,
[Status] TINYINT NOT NULL,
CONSTRAINT [PK_ClientOrganizationMigrationRecord] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [PK_OrganizationIdProviderId] UNIQUE ([ProviderId], [OrganizationId])
);

View File

@ -0,0 +1,6 @@
CREATE VIEW [dbo].[ClientOrganizationMigrationRecordView]
AS
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecord]

View File

@ -19,4 +19,7 @@
<SuppressTSqlWarnings>71502</SuppressTSqlWarnings>
</Build>
</ItemGroup>
<ItemGroup>
<Folder Include="Billing\dbo\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,175 @@
-- Table
IF OBJECT_ID('[dbo].[ClientOrganizationMigrationRecord]') IS NULL
BEGIN
CREATE TABLE [dbo].[ClientOrganizationMigrationRecord] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
[ProviderId] UNIQUEIDENTIFIER NOT NULL,
[PlanType] TINYINT NOT NULL,
[Seats] SMALLINT NOT NULL,
[MaxStorageGb] SMALLINT NULL,
[GatewayCustomerId] VARCHAR(50) NOT NULL,
[GatewaySubscriptionId] VARCHAR(50) NOT NULL,
[ExpirationDate] DATETIME2(7) NULL,
[MaxAutoscaleSeats] INT NULL,
[Status] TINYINT NOT NULL,
CONSTRAINT [PK_ClientOrganizationMigrationRecord] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [PK_OrganizationIdProviderId] UNIQUE ([ProviderId], [OrganizationId])
);
END
GO
-- View
CREATE OR AlTER VIEW [dbo].[ClientOrganizationMigrationRecordView]
AS
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecord]
GO
-- Stored Procedures: Create
CREATE OR ALTER PROCEDURE [dbo].[ClientOrganizationMigrationRecord_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@ProviderId UNIQUEIDENTIFIER,
@PlanType TINYINT,
@Seats SMALLINT,
@MaxStorageGb SMALLINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ExpirationDate DATETIME2(7),
@MaxAutoscaleSeats INT,
@Status TINYINT
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[ClientOrganizationMigrationRecord]
(
[Id],
[OrganizationId],
[ProviderId],
[PlanType],
[Seats],
[MaxStorageGb],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ExpirationDate],
[MaxAutoscaleSeats],
[Status]
)
VALUES
(
@Id,
@OrganizationId,
@ProviderId,
@PlanType,
@Seats,
@MaxStorageGb,
@GatewayCustomerId,
@GatewaySubscriptionId,
@ExpirationDate,
@MaxAutoscaleSeats,
@Status
)
END
GO
-- Stored Procedures: DeleteById
CREATE OR ALTER PROCEDURE [dbo].[ClientOrganizationMigrationRecord_DeleteById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[ClientOrganizationMigrationRecord]
WHERE
[Id] = @Id
END
GO
-- Stored Procedures: ReadById
CREATE OR ALTER PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecordView]
WHERE
[Id] = @Id
END
GO
-- Stored Procedures: ReadByOrganizationId
CREATE OR ALTER PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecordView]
WHERE
[OrganizationId] = @OrganizationId
END
GO
-- Stored Procedures: ReadByProviderId
CREATE OR ALTER PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadByProviderId]
@ProviderId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ClientOrganizationMigrationRecordView]
WHERE
[ProviderId] = @ProviderId
END
GO
-- Stored Procedures: Update
CREATE OR ALTER PROCEDURE [dbo].[ClientOrganizationMigrationRecord_Update]
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@ProviderId UNIQUEIDENTIFIER,
@PlanType TINYINT,
@Seats SMALLINT,
@MaxStorageGb SMALLINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ExpirationDate DATETIME2(7),
@MaxAutoscaleSeats INT,
@Status TINYINT
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[ClientOrganizationMigrationRecord]
SET
[OrganizationId] = @OrganizationId,
[ProviderId] = @ProviderId,
[PlanType] = @PlanType,
[Seats] = @Seats,
[MaxStorageGb] = @MaxStorageGb,
[GatewayCustomerId] = @GatewayCustomerId,
[GatewaySubscriptionId] = @GatewaySubscriptionId,
[ExpirationDate] = @ExpirationDate,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
[Status] = @Status
WHERE
[Id] = @Id
END
GO

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations
{
/// <inheritdoc />
public partial class AddClientOrganizationMigrationRecordTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -687,6 +687,53 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("WebAuthnCredential", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime(6)");
b.Property<string>("GatewayCustomerId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("GatewaySubscriptionId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("int");
b.Property<short?>("MaxStorageGb")
.HasColumnType("smallint");
b.Property<Guid>("OrganizationId")
.HasColumnType("char(36)");
b.Property<byte>("PlanType")
.HasColumnType("tinyint unsigned");
b.Property<Guid>("ProviderId")
.HasColumnType("char(36)");
b.Property<int>("Seats")
.HasColumnType("int");
b.Property<byte>("Status")
.HasColumnType("tinyint unsigned");
b.HasKey("Id");
b.HasIndex("ProviderId", "OrganizationId")
.IsUnique();
b.ToTable("ClientOrganizationMigrationRecord", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b =>
{
b.Property<Guid>("Id")

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations
{
/// <inheritdoc />
public partial class AddClientOrganizationMigrationRecordTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -692,6 +692,53 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("WebAuthnCredential", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("GatewayCustomerId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("GatewaySubscriptionId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("integer");
b.Property<short?>("MaxStorageGb")
.HasColumnType("smallint");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<byte>("PlanType")
.HasColumnType("smallint");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid");
b.Property<int>("Seats")
.HasColumnType("integer");
b.Property<byte>("Status")
.HasColumnType("smallint");
b.HasKey("Id");
b.HasIndex("ProviderId", "OrganizationId")
.IsUnique();
b.ToTable("ClientOrganizationMigrationRecord", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b =>
{
b.Property<Guid>("Id")

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations
{
/// <inheritdoc />
public partial class AddClientOrganizationMigrationRecordTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -676,6 +676,53 @@ namespace Bit.SqliteMigrations.Migrations
b.ToTable("WebAuthnCredential", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("TEXT");
b.Property<string>("GatewayCustomerId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("GatewaySubscriptionId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("INTEGER");
b.Property<short?>("MaxStorageGb")
.HasColumnType("INTEGER");
b.Property<Guid>("OrganizationId")
.HasColumnType("TEXT");
b.Property<byte>("PlanType")
.HasColumnType("INTEGER");
b.Property<Guid>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("Seats")
.HasColumnType("INTEGER");
b.Property<byte>("Status")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProviderId", "OrganizationId")
.IsUnique();
b.ToTable("ClientOrganizationMigrationRecord", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b =>
{
b.Property<Guid>("Id")