mirror of
https://github.com/bitwarden/server.git
synced 2024-12-22 16:57:36 +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:
parent
738febf031
commit
0496085c39
83
src/Admin/Billing/Controllers/MigrateProvidersController.cs
Normal file
83
src/Admin/Billing/Controllers/MigrateProvidersController.cs
Normal 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()
|
||||
: [];
|
||||
}
|
10
src/Admin/Billing/Models/MigrateProvidersRequestModel.cs
Normal file
10
src/Admin/Billing/Models/MigrateProvidersRequestModel.cs
Normal 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; }
|
||||
}
|
39
src/Admin/Billing/Views/MigrateProviders/Details.cshtml
Normal file
39
src/Admin/Billing/Views/MigrateProviders/Details.cshtml
Normal 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>
|
46
src/Admin/Billing/Views/MigrateProviders/Index.cshtml
Normal file
46
src/Admin/Billing/Views/MigrateProviders/Index.cshtml
Normal 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>
|
28
src/Admin/Billing/Views/MigrateProviders/Results.cshtml
Normal file
28
src/Admin/Billing/Views/MigrateProviders/Results.cshtml
Normal 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>
|
@ -48,5 +48,6 @@ public enum Permission
|
||||
Tools_ManageTaxRates,
|
||||
Tools_ManageStripeSubscriptions,
|
||||
Tools_CreateEditTransaction,
|
||||
Tools_ProcessStripeEvents
|
||||
Tools_ProcessStripeEvents,
|
||||
Tools_MigrateProviders
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -163,6 +163,7 @@ public static class RolePermissionMapping
|
||||
Permission.Tools_ManageStripeSubscriptions,
|
||||
Permission.Tools_CreateEditTransaction,
|
||||
Permission.Tools_ProcessStripeEvents,
|
||||
Permission.Tools_MigrateProviders
|
||||
}
|
||||
},
|
||||
{ "sales", new List<Permission>
|
||||
|
@ -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;
|
||||
@ -108,12 +109,18 @@
|
||||
Manage Stripe Subscriptions
|
||||
</a>
|
||||
}
|
||||
@if (canProcessStripeEvents)
|
||||
@if (canProcessStripeEvents)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="ProcessStripeEvents" asp-action="Index">
|
||||
Process Stripe Events
|
||||
</a>
|
||||
}
|
||||
@if (canMigrateProviders)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
|
||||
Migrate Providers
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
23
src/Core/Billing/Migration/Models/ClientMigrationTracker.cs
Normal file
23
src/Core/Billing/Migration/Models/ClientMigrationTracker.cs
Normal 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;
|
||||
}
|
45
src/Core/Billing/Migration/Models/ProviderMigrationResult.cs
Normal file
45
src/Core/Billing/Migration/Models/ProviderMigrationResult.cs
Normal 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; }
|
||||
}
|
@ -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;
|
||||
}
|
15
src/Core/Billing/Migration/ServiceCollectionExtensions.cs
Normal file
15
src/Core/Billing/Migration/ServiceCollectionExtensions.cs
Normal 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>();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.Billing.Migration.Services;
|
||||
|
||||
public interface IOrganizationMigrator
|
||||
{
|
||||
Task Migrate(Guid providerId, Organization organization);
|
||||
}
|
10
src/Core/Billing/Migration/Services/IProviderMigrator.cs
Normal file
10
src/Core/Billing/Migration/Services/IProviderMigrator.cs
Normal 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);
|
||||
}
|
@ -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)
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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);
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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
|
@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_DeleteById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[ClientOrganizationMigrationRecord]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[ClientOrganizationMigrationRecordView]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadByOrganizationId]
|
||||
@OrganizationId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[ClientOrganizationMigrationRecordView]
|
||||
WHERE
|
||||
[OrganizationId] = @OrganizationId
|
||||
END
|
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[ClientOrganizationMigrationRecord_ReadByProviderId]
|
||||
@ProviderId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[ClientOrganizationMigrationRecordView]
|
||||
WHERE
|
||||
[ProviderId] = @ProviderId
|
||||
END
|
@ -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
|
@ -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])
|
||||
);
|
@ -0,0 +1,6 @@
|
||||
CREATE VIEW [dbo].[ClientOrganizationMigrationRecordView]
|
||||
AS
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[ClientOrganizationMigrationRecord]
|
@ -19,4 +19,7 @@
|
||||
<SuppressTSqlWarnings>71502</SuppressTSqlWarnings>
|
||||
</Build>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\dbo\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
@ -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
|
2845
util/MySqlMigrations/Migrations/20241004140901_AddClientOrganizationMigrationRecordTable.Designer.cs
generated
Normal file
2845
util/MySqlMigrations/Migrations/20241004140901_AddClientOrganizationMigrationRecordTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
2851
util/PostgresMigrations/Migrations/20241004140910_AddClientOrganizationMigrationRecordTable.Designer.cs
generated
Normal file
2851
util/PostgresMigrations/Migrations/20241004140910_AddClientOrganizationMigrationRecordTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
2834
util/SqliteMigrations/Migrations/20241004140906_AddClientOrganizationMigrationRecordTable.Designer.cs
generated
Normal file
2834
util/SqliteMigrations/Migrations/20241004140906_AddClientOrganizationMigrationRecordTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user