[PM-20452] - Offloading Stripe Update (#6034)

* Adding job to update stripe subscriptions and increment seat count  when inviting a user.

* Updating name

* Added ef migrations

* Fixing script

* Fixing procedures. Added repo tests.

* Fixed set stored procedure. Fixed parameter name.

* Added tests for database calls and updated stored procedures

* Fixed build for sql file.

* fixing sproc

* File is nullsafe

* Adding view to select from instead of table.

* Updating UpdateSubscriptionStatus to use a CTE and do all the updates in 1 statement.

* Setting revision date when incrementing seat count

* Added feature flag check for the background job.

* Fixing nullable property.

* Removing new table and just adding the column to org. Updating to query and command. Updated tests.

* Adding migration script rename

* Add SyncSeats to Org.sql def

* Adding contraint name

* Removing old table files.

* Added tests

* Upped the frequency to be at the top of every 3rd hour.

* Updating error message.

* Removing extension method

* Changed to GuidIdArray

* Added xml doc and switched class to record
This commit is contained in:
Jared McCannon 2025-07-31 07:54:51 -05:00 committed by GitHub
parent 88dd977848
commit 86ce3a86e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 10968 additions and 43 deletions

View File

@ -0,0 +1,35 @@
using System.Collections.Immutable;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Jobs;
using Bit.Core.Services;
using Quartz;
namespace Bit.Api.AdminConsole.Jobs;
public class OrganizationSubscriptionUpdateJob(ILogger<OrganizationSubscriptionUpdateJob> logger,
IGetOrganizationSubscriptionsToUpdateQuery query,
IUpdateOrganizationSubscriptionCommand command,
IFeatureService featureService) : BaseJob(logger)
{
protected override async Task ExecuteJobAsync(IJobExecutionContext _)
{
if (!featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization))
{
return;
}
logger.LogInformation("OrganizationSubscriptionUpdateJob - START");
var organizationSubscriptionsToUpdate =
(await query.GetOrganizationSubscriptionsToUpdateAsync())
.ToImmutableList();
logger.LogInformation("OrganizationSubscriptionUpdateJob - {numberOfOrganizations} organization(s) to update",
organizationSubscriptionsToUpdate.Count);
await command.UpdateOrganizationSubscriptionAsync(organizationSubscriptionsToUpdate);
logger.LogInformation("OrganizationSubscriptionUpdateJob - COMPLETED");
}
}

View File

@ -1,4 +1,5 @@
using Bit.Api.Auth.Jobs; using Bit.Api.AdminConsole.Jobs;
using Bit.Api.Auth.Jobs;
using Bit.Core.Jobs; using Bit.Core.Jobs;
using Bit.Core.Settings; using Bit.Core.Settings;
using Quartz; using Quartz;
@ -65,6 +66,11 @@ public class JobsHostedService : BaseJobsHostedService
.WithIntervalInHours(24) .WithIntervalInHours(24)
.RepeatForever()) .RepeatForever())
.Build(); .Build();
var updateOrgSubscriptionsTrigger = TriggerBuilder.Create()
.WithIdentity("UpdateOrgSubscriptionsTrigger")
.StartNow()
.WithCronSchedule("0 0 */3 * * ?") // top of every 3rd hour
.Build();
var jobs = new List<Tuple<Type, ITrigger>> var jobs = new List<Tuple<Type, ITrigger>>
@ -76,6 +82,7 @@ public class JobsHostedService : BaseJobsHostedService
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger),
}; };
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication) if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
@ -105,6 +112,7 @@ public class JobsHostedService : BaseJobsHostedService
services.AddTransient<ValidateOrganizationsJob>(); services.AddTransient<ValidateOrganizationsJob>();
services.AddTransient<ValidateOrganizationDomainJob>(); services.AddTransient<ValidateOrganizationDomainJob>();
services.AddTransient<UpdatePhishingDomainsJob>(); services.AddTransient<UpdatePhishingDomainsJob>();
services.AddTransient<OrganizationSubscriptionUpdateJob>();
} }
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services) public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)

View File

@ -123,6 +123,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// </summary> /// </summary>
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
/// <summary>
/// If set to true, organization needs their seat count synced with their subscription
/// </summary>
public bool SyncSeats { get; set; }
public void SetNewId() public void SetNewId()
{ {
if (Id == default(Guid)) if (Id == default(Guid))

View File

@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.AdminConsole.Models.Data.Organizations;
public record OrganizationSubscriptionUpdate
{
public required Organization Organization { get; set; }
public int Seats => Organization.Seats ?? 0;
public Plan? Plan { get; set; }
}

View File

@ -25,7 +25,6 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
public class InviteOrganizationUsersCommand(IEventService eventService, public class InviteOrganizationUsersCommand(IEventService eventService,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IInviteUsersValidator inviteUsersValidator, IInviteUsersValidator inviteUsersValidator,
IPaymentService paymentService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IMailService mailService, IMailService mailService,
@ -190,12 +189,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 }) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 })
{ {
await paymentService.AdjustSeatsAsync(organization,
validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
@ -297,13 +290,14 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 }) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 })
{ {
await paymentService.AdjustSeatsAsync(organization, await organizationRepository.IncrementSeatCountAsync(
validatedResult.Value.InviteOrganization.Plan, organization.Id,
validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value); validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd,
validatedResult.Value.PerformedAt.UtcDateTime);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; organization.Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
organization.SyncSeats = true;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization); await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
} }
} }

View File

@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Errors;
using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.AdminConsole.Utilities.Validation;

View File

@ -0,0 +1,24 @@
using Bit.Core.AdminConsole.Models.Data.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Pricing;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
public class GetOrganizationSubscriptionsToUpdateQuery(IOrganizationRepository organizationRepository,
IPricingClient pricingClient) : IGetOrganizationSubscriptionsToUpdateQuery
{
public async Task<IEnumerable<OrganizationSubscriptionUpdate>> GetOrganizationSubscriptionsToUpdateAsync()
{
var organizationsToUpdateTask = organizationRepository.GetOrganizationsForSubscriptionSyncAsync();
var plansTask = pricingClient.ListPlans();
await Task.WhenAll(organizationsToUpdateTask, plansTask);
return organizationsToUpdateTask.Result.Select(o => new OrganizationSubscriptionUpdate
{
Organization = o,
Plan = plansTask.Result.FirstOrDefault(plan => plan.Type == o.PlanType)
});
}
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Models.Data.Organizations;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
public interface IGetOrganizationSubscriptionsToUpdateQuery
{
/// <summary>
/// Retrieves a collection of organization subscriptions that need to be updated. This is based on if the
/// Organization.SyncSeats flag is true and Organization.Seats has a value.
/// </summary>
/// <returns>
/// A collection of <see cref="OrganizationSubscriptionUpdate"/> instances, each representing an organization
/// subscription to be updated with their associated plan.
/// </returns>
Task<IEnumerable<OrganizationSubscriptionUpdate>> GetOrganizationSubscriptionsToUpdateAsync();
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Models.Data.Organizations;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
public interface IUpdateOrganizationSubscriptionCommand
{
/// <summary>
/// Attempts to update the subscription of all organizations that have had a subscription update.
///
/// If successful, the Organization.SyncSeats flag will be set to false and Organization.RevisionDate will be set.
///
/// In the event of a failure, it will log the failure and maybe be picked up in later runs.
/// </summary>
/// <param name="subscriptionsToUpdate">The collection of organization subscriptions to update.</param>
Task UpdateOrganizationSubscriptionAsync(IEnumerable<OrganizationSubscriptionUpdate> subscriptionsToUpdate);
}

View File

@ -0,0 +1,43 @@
using Bit.Core.AdminConsole.Models.Data.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
public class UpdateOrganizationSubscriptionCommand(IPaymentService paymentService,
IOrganizationRepository repository,
TimeProvider timeProvider,
ILogger<UpdateOrganizationSubscriptionCommand> logger) : IUpdateOrganizationSubscriptionCommand
{
public async Task UpdateOrganizationSubscriptionAsync(IEnumerable<OrganizationSubscriptionUpdate> subscriptionsToUpdate)
{
var successfulSyncs = new List<Guid>();
foreach (var subscriptionUpdate in subscriptionsToUpdate)
{
try
{
await paymentService.AdjustSeatsAsync(subscriptionUpdate.Organization,
subscriptionUpdate.Plan,
subscriptionUpdate.Seats);
successfulSyncs.Add(subscriptionUpdate.Organization.Id);
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to update organization {organizationId} subscription.",
subscriptionUpdate.Organization.Id);
}
}
if (successfulSyncs.Count == 0)
{
return;
}
await repository.UpdateSuccessfulOrganizationSyncStatusAsync(successfulSyncs, timeProvider.GetUtcNow().UtcDateTime);
}
}

View File

@ -24,6 +24,7 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
/// Gets the organizations that have a verified domain matching the user's email domain. /// Gets the organizations that have a verified domain matching the user's email domain.
/// </summary> /// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId); Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids); Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids);
@ -36,4 +37,29 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param> /// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
/// <returns>The number of occupied seats for the organization.</returns> /// <returns>The number of occupied seats for the organization.</returns>
Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
/// <summary>
/// Get all organizations that need to have their seat count updated to their Stripe subscription.
/// </summary>
/// <returns>Organizations to sync to Stripe</returns>
Task<IEnumerable<Organization>> GetOrganizationsForSubscriptionSyncAsync();
/// <summary>
/// Updates the organization SeatSync property to signify the organization's subscription has been updated in stripe
/// to match the password manager seats for the organization.
/// </summary>
/// <param name="successfulOrganizations"></param>
/// <param name="syncDate"></param>
/// <returns></returns>
Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable<Guid> successfulOrganizations, DateTime syncDate);
/// <summary>
/// This increments the password manager seat count on the organization by the provided amount and sets SyncSeats to true.
/// It also sets the revision date using the request date.
/// </summary>
/// <param name="organizationId">Organization to update</param>
/// <param name="increaseAmount">Amount to increase password manager seats by</param>
/// <param name="requestDate">When the action was performed</param>
/// <returns></returns>
Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate);
} }

View File

@ -1,4 +1,6 @@
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
@ -7,7 +9,10 @@ public static class OrganizationSubscriptionServiceCollectionExtensions
{ {
public static void AddOrganizationSubscriptionServices(this IServiceCollection services) public static void AddOrganizationSubscriptionServices(this IServiceCollection services)
{ {
services.AddScoped<IUpgradeOrganizationPlanCommand, UpgradeOrganizationPlanCommand>(); services
services.AddScoped<IAddSecretsManagerSubscriptionCommand, AddSecretsManagerSubscriptionCommand>(); .AddScoped<IUpgradeOrganizationPlanCommand, UpgradeOrganizationPlanCommand>()
.AddScoped<IAddSecretsManagerSubscriptionCommand, AddSecretsManagerSubscriptionCommand>()
.AddScoped<IGetOrganizationSubscriptionsToUpdateQuery, GetOrganizationSubscriptionsToUpdateQuery>()
.AddScoped<IUpdateOrganizationSubscriptionCommand, UpdateOrganizationSubscriptionCommand>();
} }
} }

View File

@ -25,6 +25,14 @@ public interface IPaymentService
int? newlyPurchasedSecretsManagerSeats, int? newlyPurchasedSecretsManagerSeats,
int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, int? newlyPurchasedAdditionalSecretsManagerServiceAccounts,
int newlyPurchasedAdditionalStorage); int newlyPurchasedAdditionalStorage);
/// <summary>
/// Used to update the organization's password manager subscription
/// </summary>
/// <param name="organization"></param>
/// <param name="plan"></param>
/// <param name="additionalSeats">New seat total</param>
/// <returns></returns>
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats); Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats);
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats); Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId);

View File

@ -220,4 +220,35 @@ public class OrganizationRepository : Repository<Organization, Guid>, IOrganizat
return result.SingleOrDefault() ?? new OrganizationSeatCounts(); return result.SingleOrDefault() ?? new OrganizationSeatCounts();
} }
} }
public async Task<IEnumerable<Organization>> GetOrganizationsForSubscriptionSyncAsync()
{
await using var connection = new SqlConnection(ConnectionString);
return await connection.QueryAsync<Organization>(
"[dbo].[Organization_GetOrganizationsForSubscriptionSync]",
commandType: CommandType.StoredProcedure);
}
public async Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable<Guid> successfulOrganizations, DateTime syncDate)
{
await using var connection = new SqlConnection(ConnectionString);
await connection.ExecuteAsync("[dbo].[Organization_UpdateSubscriptionStatus]",
new
{
SuccessfulOrganizations = successfulOrganizations.ToGuidIdArrayTVP(),
SyncDate = syncDate
},
commandType: CommandType.StoredProcedure);
}
public async Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate)
{
await using var connection = new SqlConnection(ConnectionString);
await connection.ExecuteAsync("[dbo].[Organization_IncrementSeatCount]",
new { OrganizationId = organizationId, SeatsToAdd = increaseAmount, RequestDate = requestDate },
commandType: CommandType.StoredProcedure);
}
} }

View File

@ -660,12 +660,14 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
{ {
await using var connection = new SqlConnection(_marsConnectionString); await using var connection = new SqlConnection(_marsConnectionString);
var organizationUsersList = organizationUserCollection.ToList();
await connection.ExecuteAsync( await connection.ExecuteAsync(
$"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]",
new new
{ {
OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.OrganizationUser)), OrganizationUserData = JsonSerializer.Serialize(organizationUsersList.Select(x => x.OrganizationUser)),
CollectionData = JsonSerializer.Serialize(organizationUserCollection CollectionData = JsonSerializer.Serialize(organizationUsersList
.SelectMany(x => x.Collections, (user, collection) => new CollectionUser .SelectMany(x => x.Collections, (user, collection) => new CollectionUser
{ {
CollectionId = collection.Id, CollectionId = collection.Id,
@ -674,7 +676,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
HidePasswords = collection.HidePasswords, HidePasswords = collection.HidePasswords,
Manage = collection.Manage Manage = collection.Manage
})), })),
GroupData = JsonSerializer.Serialize(organizationUserCollection GroupData = JsonSerializer.Serialize(organizationUsersList
.SelectMany(x => x.Groups, (user, group) => new GroupUser .SelectMany(x => x.Groups, (user, group) => new GroupUser
{ {
GroupId = group, GroupId = group,

View File

@ -403,4 +403,41 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
}; };
} }
} }
public async Task<IEnumerable<Core.AdminConsole.Entities.Organization>> GetOrganizationsForSubscriptionSyncAsync()
{
using var scope = ServiceScopeFactory.CreateScope();
await using var dbContext = GetDatabaseContext(scope);
var organizations = await dbContext.Organizations
.Where(o => o.SyncSeats == true && o.Seats != null)
.ToArrayAsync();
return organizations;
}
public async Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable<Guid> successfulOrganizations, DateTime syncDate)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var dbContext = GetDatabaseContext(scope);
await dbContext.Organizations
.Where(o => successfulOrganizations.Contains(o.Id))
.ExecuteUpdateAsync(o => o
.SetProperty(x => x.SyncSeats, false)
.SetProperty(x => x.RevisionDate, syncDate.Date));
}
public async Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var dbContext = GetDatabaseContext(scope);
await dbContext.Organizations
.Where(o => o.Id == organizationId)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Seats, o => o.Seats + increaseAmount)
.SetProperty(o => o.SyncSeats, true)
.SetProperty(o => o.RevisionDate, requestDate));
}
} }

View File

@ -57,7 +57,8 @@ CREATE PROCEDURE [dbo].[Organization_Create]
@UseRiskInsights BIT = 0, @UseRiskInsights BIT = 0,
@LimitItemDeletion BIT = 0, @LimitItemDeletion BIT = 0,
@UseOrganizationDomains BIT = 0, @UseOrganizationDomains BIT = 0,
@UseAdminSponsoredFamilies BIT = 0 @UseAdminSponsoredFamilies BIT = 0,
@SyncSeats BIT = 0
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -122,7 +123,8 @@ BEGIN
[UseRiskInsights], [UseRiskInsights],
[LimitItemDeletion], [LimitItemDeletion],
[UseOrganizationDomains], [UseOrganizationDomains],
[UseAdminSponsoredFamilies] [UseAdminSponsoredFamilies],
[SyncSeats]
) )
VALUES VALUES
( (
@ -184,6 +186,7 @@ BEGIN
@UseRiskInsights, @UseRiskInsights,
@LimitItemDeletion, @LimitItemDeletion,
@UseOrganizationDomains, @UseOrganizationDomains,
@UseAdminSponsoredFamilies @UseAdminSponsoredFamilies,
@SyncSeats
) )
END END

View File

@ -0,0 +1,7 @@
CREATE PROCEDURE [dbo].[Organization_GetOrganizationsForSubscriptionSync]
AS
BEGIN
SELECT *
FROM [dbo].[OrganizationView]
WHERE [Seats] IS NOT NULL AND [SyncSeats] = 1
END

View File

@ -0,0 +1,15 @@
CREATE PROCEDURE [dbo].[Organization_IncrementSeatCount]
@OrganizationId UNIQUEIDENTIFIER,
@SeatsToAdd INT,
@RequestDate DATETIME2
AS
BEGIN
SET NOCOUNT ON
UPDATE [dbo].[Organization]
SET
[Seats] = [Seats] + @SeatsToAdd,
[SyncSeats] = 1,
[RevisionDate] = @RequestDate
WHERE [Id] = @OrganizationId
END

View File

@ -57,7 +57,8 @@ CREATE PROCEDURE [dbo].[Organization_Update]
@UseRiskInsights BIT = 0, @UseRiskInsights BIT = 0,
@LimitItemDeletion BIT = 0, @LimitItemDeletion BIT = 0,
@UseOrganizationDomains BIT = 0, @UseOrganizationDomains BIT = 0,
@UseAdminSponsoredFamilies BIT = 0 @UseAdminSponsoredFamilies BIT = 0,
@SyncSeats BIT = 0
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -122,7 +123,8 @@ BEGIN
[UseRiskInsights] = @UseRiskInsights, [UseRiskInsights] = @UseRiskInsights,
[LimitItemDeletion] = @LimitItemDeletion, [LimitItemDeletion] = @LimitItemDeletion,
[UseOrganizationDomains] = @UseOrganizationDomains, [UseOrganizationDomains] = @UseOrganizationDomains,
[UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies,
[SyncSeats] = @SyncSeats
WHERE WHERE
[Id] = @Id [Id] = @Id
END END

View File

@ -0,0 +1,14 @@
CREATE PROCEDURE [dbo].[Organization_UpdateSubscriptionStatus]
@SuccessfulOrganizations AS [dbo].[GuidIdArray] READONLY,
@SyncDate DATETIME2
AS
BEGIN
SET NOCOUNT ON
UPDATE o
SET
[SyncSeats] = 0,
[RevisionDate] = @SyncDate
FROM [dbo].[Organization] o
INNER JOIN @SuccessfulOrganizations success on success.Id = o.Id
END

View File

@ -58,6 +58,7 @@ CREATE TABLE [dbo].[Organization] (
[UseRiskInsights] BIT NOT NULL CONSTRAINT [DF_Organization_UseRiskInsights] DEFAULT (0), [UseRiskInsights] BIT NOT NULL CONSTRAINT [DF_Organization_UseRiskInsights] DEFAULT (0),
[UseOrganizationDomains] BIT NOT NULL CONSTRAINT [DF_Organization_UseOrganizationDomains] DEFAULT (0), [UseOrganizationDomains] BIT NOT NULL CONSTRAINT [DF_Organization_UseOrganizationDomains] DEFAULT (0),
[UseAdminSponsoredFamilies] BIT NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] DEFAULT (0), [UseAdminSponsoredFamilies] BIT NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] DEFAULT (0),
[SyncSeats] BIT NOT NULL CONSTRAINT [DF_Organization_SyncSeats] DEFAULT (0),
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC) CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
); );

View File

@ -0,0 +1,60 @@
using Bit.Api.AdminConsole.Jobs;
using Bit.Core;
using Bit.Core.AdminConsole.Models.Data.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Quartz;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Jobs;
[SutProviderCustomize]
public class OrganizationSubscriptionUpdateJobTests
{
[Theory]
[BitAutoData]
public async Task ExecuteJobAsync_WhenScimInviteUserIsDisabled_ThenQueryAndCommandAreNotExecuted(
SutProvider<OrganizationSubscriptionUpdateJob> sutProvider)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
.Returns(false);
var contextMock = Substitute.For<IJobExecutionContext>();
await sutProvider.Sut.Execute(contextMock);
await sutProvider.GetDependency<IGetOrganizationSubscriptionsToUpdateQuery>()
.DidNotReceive()
.GetOrganizationSubscriptionsToUpdateAsync();
await sutProvider.GetDependency<IUpdateOrganizationSubscriptionCommand>()
.DidNotReceive()
.UpdateOrganizationSubscriptionAsync(Arg.Any<IEnumerable<OrganizationSubscriptionUpdate>>());
}
[Theory]
[BitAutoData]
public async Task ExecuteJobAsync_WhenScimInviteUserIsEnabled_ThenQueryAndCommandAreExecuted(
SutProvider<OrganizationSubscriptionUpdateJob> sutProvider)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
.Returns(true);
var contextMock = Substitute.For<IJobExecutionContext>();
await sutProvider.Sut.Execute(contextMock);
await sutProvider.GetDependency<IGetOrganizationSubscriptionsToUpdateQuery>()
.Received(1)
.GetOrganizationSubscriptionsToUpdateAsync();
await sutProvider.GetDependency<IUpdateOrganizationSubscriptionCommand>()
.Received(1)
.UpdateOrganizationSubscriptionAsync(Arg.Any<IEnumerable<OrganizationSubscriptionUpdate>>());
}
}

View File

@ -19,7 +19,6 @@ using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -81,10 +80,6 @@ public class InviteOrganizationUserCommandTests
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result); Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
Assert.Equal(NoUsersToInviteError.Code, (result as Failure<ScimInviteOrganizationUsersResponse>)!.Error.Message); Assert.Equal(NoUsersToInviteError.Code, (result as Failure<ScimInviteOrganizationUsersResponse>)!.Error.Message);
await sutProvider.GetDependency<IPaymentService>()
.DidNotReceiveWithAnyArgs()
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>() await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.DidNotReceiveWithAnyArgs() .DidNotReceiveWithAnyArgs()
.SendInvitesAsync(Arg.Any<SendInvitesRequest>()); .SendInvitesAsync(Arg.Any<SendInvitesRequest>());
@ -458,10 +453,7 @@ public class InviteOrganizationUserCommandTests
// Assert // Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result); Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
await sutProvider.GetDependency<IPaymentService>() await orgRepository.Received(1).IncrementSeatCountAsync(organization.Id, passwordManagerUpdate.SeatsRequiredToAdd, request.PerformedAt.UtcDateTime);
.AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.UpdatedSeatTotal!.Value);
await orgRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));
await sutProvider.GetDependency<IApplicationCacheService>() await sutProvider.GetDependency<IApplicationCacheService>()
.Received(1) .Received(1)
@ -632,11 +624,7 @@ public class InviteOrganizationUserCommandTests
.UpdateSubscriptionAsync(Arg.Any<SecretsManagerSubscriptionUpdate>()); .UpdateSubscriptionAsync(Arg.Any<SecretsManagerSubscriptionUpdate>());
// PM revert // PM revert
await sutProvider.GetDependency<IPaymentService>() await orgRepository.Received(1).ReplaceAsync(Arg.Any<Organization>());
.Received(2)
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await orgRepository.Received(2).ReplaceAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IApplicationCacheService>().Received(2) await sutProvider.GetDependency<IApplicationCacheService>().Received(2)
.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()); .UpsertOrganizationAbilityAsync(Arg.Any<Organization>());

View File

@ -0,0 +1,54 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
[SutProviderCustomize]
public class GetOrganizationSubscriptionsToUpdateQueryTests
{
[Theory]
[BitAutoData]
public async Task GetOrganizationSubscriptionsToUpdateAsync_WhenNoOrganizationsNeedToBeSynced_ThenAnEmptyListIsReturned(
SutProvider<GetOrganizationSubscriptionsToUpdateQuery> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>()
.GetOrganizationsForSubscriptionSyncAsync()
.Returns([]);
var result = await sutProvider.Sut.GetOrganizationSubscriptionsToUpdateAsync();
Assert.Empty(result);
}
[Theory]
[BitAutoData]
public async Task GetOrganizationSubscriptionsToUpdateAsync_WhenOrganizationsNeedToBeSynced_ThenUpdateIsReturnedWithCorrectPlanAndOrg(
Organization organization,
SutProvider<GetOrganizationSubscriptionsToUpdateQuery> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually2023;
sutProvider.GetDependency<IOrganizationRepository>()
.GetOrganizationsForSubscriptionSyncAsync()
.Returns([organization]);
sutProvider.GetDependency<IPricingClient>()
.ListPlans()
.Returns([new Enterprise2023Plan(true)]);
var result = await sutProvider.Sut.GetOrganizationSubscriptionsToUpdateAsync();
var matchingUpdate = result.FirstOrDefault(x => x.Organization.Id == organization.Id);
Assert.NotNull(matchingUpdate);
Assert.Equal(organization.PlanType, matchingUpdate.Plan!.Type);
Assert.Equal(organization, matchingUpdate.Organization);
}
}

View File

@ -0,0 +1,146 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Models.StaticStore;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
[SutProviderCustomize]
public class UpdateOrganizationSubscriptionCommandTests
{
[Theory]
[BitAutoData]
public async Task UpdateOrganizationSubscriptionAsync_WhenNoSubscriptionsNeedToBeUpdated_ThenNoSyncsOccur(
SutProvider<UpdateOrganizationSubscriptionCommand> sutProvider)
{
// Arrange
OrganizationSubscriptionUpdate[] subscriptionsToUpdate = [];
// Act
await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate);
await sutProvider.GetDependency<IPaymentService>()
.DidNotReceive()
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<DateTime>());
}
[Theory]
[BitAutoData]
public async Task UpdateOrganizationSubscriptionAsync_WhenOrgUpdatePassedIn_ThenSyncedThroughPaymentService(
Organization organization,
SutProvider<UpdateOrganizationSubscriptionCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.EnterpriseAnnually2023;
organization.Seats = 2;
OrganizationSubscriptionUpdate[] subscriptionsToUpdate =
[new() { Organization = organization, Plan = new Enterprise2023Plan(true) }];
// Act
await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate);
await sutProvider.GetDependency<IPaymentService>()
.Received(1)
.AdjustSeatsAsync(
Arg.Is<Organization>(x => x.Id == organization.Id),
Arg.Is<Plan>(x => x.Type == organization.PlanType),
organization.Seats!.Value);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpdateSuccessfulOrganizationSyncStatusAsync(
Arg.Is<IEnumerable<Guid>>(x => x.Contains(organization.Id)),
Arg.Any<DateTime>());
}
[Theory]
[BitAutoData]
public async Task UpdateOrganizationSubscriptionAsync_WhenOrgUpdateFails_ThenSyncDoesNotOccur(
Organization organization,
Exception exception,
SutProvider<UpdateOrganizationSubscriptionCommand> sutProvider)
{
// Arrange
organization.PlanType = PlanType.EnterpriseAnnually2023;
organization.Seats = 2;
OrganizationSubscriptionUpdate[] subscriptionsToUpdate =
[new() { Organization = organization, Plan = new Enterprise2023Plan(true) }];
sutProvider.GetDependency<IPaymentService>()
.AdjustSeatsAsync(
Arg.Is<Organization>(x => x.Id == organization.Id),
Arg.Is<Plan>(x => x.Type == organization.PlanType),
organization.Seats!.Value).ThrowsAsync(exception);
// Act
await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate);
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<DateTime>());
}
[Theory]
[BitAutoData]
public async Task UpdateOrganizationSubscriptionAsync_WhenOneOrgUpdateFailsAndAnotherSucceeds_ThenSyncOccursForTheSuccessfulOrg(
Organization successfulOrganization,
Organization failedOrganization,
Exception exception,
SutProvider<UpdateOrganizationSubscriptionCommand> sutProvider)
{
// Arrange
successfulOrganization.PlanType = PlanType.EnterpriseAnnually2023;
successfulOrganization.Seats = 2;
failedOrganization.PlanType = PlanType.EnterpriseAnnually2023;
failedOrganization.Seats = 2;
OrganizationSubscriptionUpdate[] subscriptionsToUpdate =
[
new() { Organization = successfulOrganization, Plan = new Enterprise2023Plan(true) },
new() { Organization = failedOrganization, Plan = new Enterprise2023Plan(true) }
];
sutProvider.GetDependency<IPaymentService>()
.AdjustSeatsAsync(
Arg.Is<Organization>(x => x.Id == failedOrganization.Id),
Arg.Is<Plan>(x => x.Type == failedOrganization.PlanType),
failedOrganization.Seats!.Value).ThrowsAsync(exception);
// Act
await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate);
await sutProvider.GetDependency<IPaymentService>()
.Received(1)
.AdjustSeatsAsync(
Arg.Is<Organization>(x => x.Id == successfulOrganization.Id),
Arg.Is<Plan>(x => x.Type == successfulOrganization.PlanType),
successfulOrganization.Seats!.Value);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpdateSuccessfulOrganizationSyncStatusAsync(
Arg.Is<IEnumerable<Guid>>(x => x.Contains(successfulOrganization.Id)),
Arg.Any<DateTime>());
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpdateSuccessfulOrganizationSyncStatusAsync(
Arg.Is<IEnumerable<Guid>>(x => x.Contains(failedOrganization.Id)),
Arg.Any<DateTime>());
}
}

View File

@ -31,13 +31,15 @@ public static class OrganizationTestHelpers
/// Creates an Enterprise organization. /// Creates an Enterprise organization.
/// </summary> /// </summary>
public static Task<Organization> CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository, public static Task<Organization> CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository,
int? seatCount = null,
string identifier = "test") string identifier = "test")
=> organizationRepository.CreateAsync(new Organization => organizationRepository.CreateAsync(new Organization
{ {
Name = $"{identifier}-{Guid.NewGuid()}", Name = $"{identifier}-{Guid.NewGuid()}",
BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL
Plan = "Enterprise (Annually)", // TODO: EF does not enforce this being NOT NULl Plan = "Enterprise (Annually)", // TODO: EF does not enforce this being NOT NULl
PlanType = PlanType.EnterpriseAnnually PlanType = PlanType.EnterpriseAnnually,
Seats = seatCount
}); });
/// <summary> /// <summary>

View File

@ -423,4 +423,111 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Sponsored); Assert.Equal(0, result.Sponsored);
Assert.Equal(0, result.Total); Assert.Equal(0, result.Total);
} }
[DatabaseTheory, DatabaseData]
public async Task IncrementSeatCountAsync_IncrementsSeatCount(IOrganizationRepository organizationRepository)
{
var organization = await organizationRepository.CreateTestOrganizationAsync();
organization.Seats = 5;
await organizationRepository.ReplaceAsync(organization);
await organizationRepository.IncrementSeatCountAsync(organization.Id, 3, DateTime.UtcNow);
var result = await organizationRepository.GetByIdAsync(organization.Id);
Assert.NotNull(result);
Assert.Equal(8, result.Seats);
}
[DatabaseData, DatabaseTheory]
public async Task IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
IOrganizationRepository sutRepository)
{
// Arrange
var organization = await sutRepository.CreateTestOrganizationAsync(seatCount: 2);
var requestDate = DateTime.UtcNow;
// Act
await sutRepository.IncrementSeatCountAsync(organization.Id, 1, requestDate);
// Assert
var result = (await sutRepository.GetOrganizationsForSubscriptionSyncAsync()).ToArray();
var updateResult = result.FirstOrDefault(x => x.Id == organization.Id);
Assert.NotNull(updateResult);
Assert.Equal(organization.Id, updateResult.Id);
Assert.True(updateResult.SyncSeats);
Assert.Equal(requestDate.ToString("yyyy-MM-dd HH:mm:ss"), updateResult.RevisionDate.ToString("yyyy-MM-dd HH:mm:ss"));
// Annul
await sutRepository.DeleteAsync(organization);
}
[DatabaseData, DatabaseTheory]
public async Task IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
IOrganizationRepository sutRepository)
{
// Arrange
var organization = await sutRepository.CreateTestOrganizationAsync(seatCount: 2);
await sutRepository.IncrementSeatCountAsync(organization.Id, 1, DateTime.UtcNow);
var requestDate = DateTime.UtcNow;
// Act
await sutRepository.IncrementSeatCountAsync(organization.Id, 1, DateTime.UtcNow);
// Assert
var result = (await sutRepository.GetOrganizationsForSubscriptionSyncAsync()).ToArray();
var updateResult = result.FirstOrDefault(x => x.Id == organization.Id);
Assert.NotNull(updateResult);
Assert.Equal(organization.Id, updateResult.Id);
Assert.True(updateResult.SyncSeats);
Assert.Equal(requestDate.ToString("yyyy-MM-dd HH:mm:ss"), updateResult.RevisionDate.ToString("yyyy-MM-dd HH:mm:ss"));
// Annul
await sutRepository.DeleteAsync(organization);
}
[DatabaseData, DatabaseTheory]
public async Task GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate(
IOrganizationRepository sutRepository)
{
// Arrange
var organization = await sutRepository.CreateTestOrganizationAsync(seatCount: 2);
var requestDate = DateTime.UtcNow;
await sutRepository.IncrementSeatCountAsync(organization.Id, 1, requestDate);
// Act
var result = (await sutRepository.GetOrganizationsForSubscriptionSyncAsync()).ToArray();
// Assert
var updateResult = result.FirstOrDefault(x => x.Id == organization.Id);
Assert.NotNull(updateResult);
Assert.Equal(organization.Id, updateResult.Id);
Assert.True(updateResult.SyncSeats);
Assert.Equal(requestDate.ToString("yyyy-MM-dd HH:mm:ss"), updateResult.RevisionDate.ToString("yyyy-MM-dd HH:mm:ss"));
// Annul
await sutRepository.DeleteAsync(organization);
}
[DatabaseData, DatabaseTheory]
public async Task UpdateSuccessfulOrganizationSyncStatusAsync_GivenOrganizationHasChangedSeatCount_WhenUpdatingStatus_ThenSuccessfullyUpdatesOrgSoItDoesntSync(
IOrganizationRepository sutRepository)
{
// Arrange
var organization = await sutRepository.CreateTestOrganizationAsync(seatCount: 2);
var requestDate = DateTime.UtcNow;
var syncDate = DateTime.UtcNow.AddMinutes(1);
await sutRepository.IncrementSeatCountAsync(organization.Id, 1, requestDate);
// Act
await sutRepository.UpdateSuccessfulOrganizationSyncStatusAsync([organization.Id], syncDate);
// Assert
var result = (await sutRepository.GetOrganizationsForSubscriptionSyncAsync()).ToArray();
Assert.Null(result.FirstOrDefault(x => x.Id == organization.Id));
// Annul
await sutRepository.DeleteAsync(organization);
}
} }

View File

@ -0,0 +1,382 @@
-- Add the new column if it doesn't exist
IF NOT EXISTS (SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'Organization'
AND COLUMN_NAME = 'SyncSeats')
BEGIN
ALTER TABLE [dbo].[Organization]
ADD [SyncSeats] BIT NOT NULL CONSTRAINT [DF_Organization_SyncSeats] DEFAULT 0;
END
GO
-- Refresh view
EXEC sp_refreshsqlmodule N'[dbo].[OrganizationView]';
GO
CREATE OR ALTER PROCEDURE [dbo].[Organization_GetOrganizationsForSubscriptionSync]
AS
BEGIN
SELECT *
FROM [dbo].[OrganizationView]
WHERE [Seats] IS NOT NULL AND [SyncSeats] = 1
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Organization_UpdateSubscriptionStatus]
@SuccessfulOrganizations AS [dbo].[GuidIdArray] READONLY,
@SyncDate DATETIME2
AS
BEGIN
SET NOCOUNT ON
UPDATE o
SET
[SyncSeats] = 0,
[RevisionDate] = @SyncDate
FROM [dbo].[Organization] o
INNER JOIN @SuccessfulOrganizations success on success.Id = o.Id
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Organization_IncrementSeatCount]
@OrganizationId UNIQUEIDENTIFIER,
@SeatsToAdd INT,
@RequestDate DATETIME2
AS
BEGIN
SET NOCOUNT ON
UPDATE [dbo].[Organization]
SET
[Seats] = [Seats] + @SeatsToAdd,
[SyncSeats] = 1,
[RevisionDate] = @RequestDate
WHERE [Id] = @OrganizationId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Organization_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@BusinessName NVARCHAR(50),
@BusinessAddress1 NVARCHAR(50),
@BusinessAddress2 NVARCHAR(50),
@BusinessAddress3 NVARCHAR(50),
@BusinessCountry VARCHAR(2),
@BusinessTaxNumber NVARCHAR(30),
@BillingEmail NVARCHAR(256),
@Plan NVARCHAR(50),
@PlanType TINYINT,
@Seats INT,
@MaxCollections SMALLINT,
@UsePolicies BIT,
@UseSso BIT,
@UseGroups BIT,
@UseDirectory BIT,
@UseEvents BIT,
@UseTotp BIT,
@Use2fa BIT,
@UseApi BIT,
@UseResetPassword BIT,
@SelfHost BIT,
@UsersGetPremium BIT,
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@Enabled BIT,
@LicenseKey VARCHAR(100),
@PublicKey VARCHAR(MAX),
@PrivateKey VARCHAR(MAX),
@TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0,
@Status TINYINT = 0,
@UsePasswordManager BIT = 1,
@SmSeats INT = null,
@SmServiceAccounts INT = null,
@MaxAutoscaleSmSeats INT= null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0,
@LimitCollectionCreation BIT = NULL,
@LimitCollectionDeletion BIT = NULL,
@AllowAdminAccessToAllCollectionItems BIT = 0,
@UseRiskInsights BIT = 0,
@LimitItemDeletion BIT = 0,
@UseOrganizationDomains BIT = 0,
@UseAdminSponsoredFamilies BIT = 0,
@SyncSeats BIT = 0
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Organization]
(
[Id],
[Identifier],
[Name],
[BusinessName],
[BusinessAddress1],
[BusinessAddress2],
[BusinessAddress3],
[BusinessCountry],
[BusinessTaxNumber],
[BillingEmail],
[Plan],
[PlanType],
[Seats],
[MaxCollections],
[UsePolicies],
[UseSso],
[UseGroups],
[UseDirectory],
[UseEvents],
[UseTotp],
[Use2fa],
[UseApi],
[UseResetPassword],
[SelfHost],
[UsersGetPremium],
[Storage],
[MaxStorageGb],
[Gateway],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ReferenceData],
[Enabled],
[LicenseKey],
[PublicKey],
[PrivateKey],
[TwoFactorProviders],
[ExpirationDate],
[CreationDate],
[RevisionDate],
[OwnersNotifiedOfAutoscaling],
[MaxAutoscaleSeats],
[UseKeyConnector],
[UseScim],
[UseCustomPermissions],
[UseSecretsManager],
[Status],
[UsePasswordManager],
[SmSeats],
[SmServiceAccounts],
[MaxAutoscaleSmSeats],
[MaxAutoscaleSmServiceAccounts],
[SecretsManagerBeta],
[LimitCollectionCreation],
[LimitCollectionDeletion],
[AllowAdminAccessToAllCollectionItems],
[UseRiskInsights],
[LimitItemDeletion],
[UseOrganizationDomains],
[UseAdminSponsoredFamilies],
[SyncSeats]
)
VALUES
(
@Id,
@Identifier,
@Name,
@BusinessName,
@BusinessAddress1,
@BusinessAddress2,
@BusinessAddress3,
@BusinessCountry,
@BusinessTaxNumber,
@BillingEmail,
@Plan,
@PlanType,
@Seats,
@MaxCollections,
@UsePolicies,
@UseSso,
@UseGroups,
@UseDirectory,
@UseEvents,
@UseTotp,
@Use2fa,
@UseApi,
@UseResetPassword,
@SelfHost,
@UsersGetPremium,
@Storage,
@MaxStorageGb,
@Gateway,
@GatewayCustomerId,
@GatewaySubscriptionId,
@ReferenceData,
@Enabled,
@LicenseKey,
@PublicKey,
@PrivateKey,
@TwoFactorProviders,
@ExpirationDate,
@CreationDate,
@RevisionDate,
@OwnersNotifiedOfAutoscaling,
@MaxAutoscaleSeats,
@UseKeyConnector,
@UseScim,
@UseCustomPermissions,
@UseSecretsManager,
@Status,
@UsePasswordManager,
@SmSeats,
@SmServiceAccounts,
@MaxAutoscaleSmSeats,
@MaxAutoscaleSmServiceAccounts,
@SecretsManagerBeta,
@LimitCollectionCreation,
@LimitCollectionDeletion,
@AllowAdminAccessToAllCollectionItems,
@UseRiskInsights,
@LimitItemDeletion,
@UseOrganizationDomains,
@UseAdminSponsoredFamilies,
@SyncSeats
)
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Organization_Update]
@Id UNIQUEIDENTIFIER,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@BusinessName NVARCHAR(50),
@BusinessAddress1 NVARCHAR(50),
@BusinessAddress2 NVARCHAR(50),
@BusinessAddress3 NVARCHAR(50),
@BusinessCountry VARCHAR(2),
@BusinessTaxNumber NVARCHAR(30),
@BillingEmail NVARCHAR(256),
@Plan NVARCHAR(50),
@PlanType TINYINT,
@Seats INT,
@MaxCollections SMALLINT,
@UsePolicies BIT,
@UseSso BIT,
@UseGroups BIT,
@UseDirectory BIT,
@UseEvents BIT,
@UseTotp BIT,
@Use2fa BIT,
@UseApi BIT,
@UseResetPassword BIT,
@SelfHost BIT,
@UsersGetPremium BIT,
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@Enabled BIT,
@LicenseKey VARCHAR(100),
@PublicKey VARCHAR(MAX),
@PrivateKey VARCHAR(MAX),
@TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0,
@Status TINYINT = 0,
@UsePasswordManager BIT = 1,
@SmSeats INT = null,
@SmServiceAccounts INT = null,
@MaxAutoscaleSmSeats INT = null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0,
@LimitCollectionCreation BIT = null,
@LimitCollectionDeletion BIT = null,
@AllowAdminAccessToAllCollectionItems BIT = 0,
@UseRiskInsights BIT = 0,
@LimitItemDeletion BIT = 0,
@UseOrganizationDomains BIT = 0,
@UseAdminSponsoredFamilies BIT = 0,
@SyncSeats BIT = 0
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Organization]
SET
[Identifier] = @Identifier,
[Name] = @Name,
[BusinessName] = @BusinessName,
[BusinessAddress1] = @BusinessAddress1,
[BusinessAddress2] = @BusinessAddress2,
[BusinessAddress3] = @BusinessAddress3,
[BusinessCountry] = @BusinessCountry,
[BusinessTaxNumber] = @BusinessTaxNumber,
[BillingEmail] = @BillingEmail,
[Plan] = @Plan,
[PlanType] = @PlanType,
[Seats] = @Seats,
[MaxCollections] = @MaxCollections,
[UsePolicies] = @UsePolicies,
[UseSso] = @UseSso,
[UseGroups] = @UseGroups,
[UseDirectory] = @UseDirectory,
[UseEvents] = @UseEvents,
[UseTotp] = @UseTotp,
[Use2fa] = @Use2fa,
[UseApi] = @UseApi,
[UseResetPassword] = @UseResetPassword,
[SelfHost] = @SelfHost,
[UsersGetPremium] = @UsersGetPremium,
[Storage] = @Storage,
[MaxStorageGb] = @MaxStorageGb,
[Gateway] = @Gateway,
[GatewayCustomerId] = @GatewayCustomerId,
[GatewaySubscriptionId] = @GatewaySubscriptionId,
[ReferenceData] = @ReferenceData,
[Enabled] = @Enabled,
[LicenseKey] = @LicenseKey,
[PublicKey] = @PublicKey,
[PrivateKey] = @PrivateKey,
[TwoFactorProviders] = @TwoFactorProviders,
[ExpirationDate] = @ExpirationDate,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
[UseKeyConnector] = @UseKeyConnector,
[UseScim] = @UseScim,
[UseCustomPermissions] = @UseCustomPermissions,
[UseSecretsManager] = @UseSecretsManager,
[Status] = @Status,
[UsePasswordManager] = @UsePasswordManager,
[SmSeats] = @SmSeats,
[SmServiceAccounts] = @SmServiceAccounts,
[MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats,
[MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts,
[SecretsManagerBeta] = @SecretsManagerBeta,
[LimitCollectionCreation] = @LimitCollectionCreation,
[LimitCollectionDeletion] = @LimitCollectionDeletion,
[AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems,
[UseRiskInsights] = @UseRiskInsights,
[LimitItemDeletion] = @LimitItemDeletion,
[UseOrganizationDomains] = @UseOrganizationDomains,
[UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies,
[SyncSeats] = @SyncSeats
WHERE
[Id] = @Id
END
GO

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class Organization_Add_Sync_Seats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "SyncSeats",
table: "Organization",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SyncSeats",
table: "Organization");
}
}

View File

@ -205,6 +205,9 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<long?>("Storage") b.Property<long?>("Storage")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<bool>("SyncSeats")
.HasColumnType("tinyint(1)");
b.Property<string>("TwoFactorProviders") b.Property<string>("TwoFactorProviders")
.HasColumnType("longtext"); .HasColumnType("longtext");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class Organization_Add_Sync_Seats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "SyncSeats",
table: "Organization",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SyncSeats",
table: "Organization");
}
}

View File

@ -207,6 +207,9 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<long?>("Storage") b.Property<long?>("Storage")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<bool>("SyncSeats")
.HasColumnType("boolean");
b.Property<string>("TwoFactorProviders") b.Property<string>("TwoFactorProviders")
.HasColumnType("text"); .HasColumnType("text");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class Organization_Add_Sync_Seats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "SyncSeats",
table: "Organization",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SyncSeats",
table: "Organization");
}
}

View File

@ -200,6 +200,9 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<long?>("Storage") b.Property<long?>("Storage")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("SyncSeats")
.HasColumnType("INTEGER");
b.Property<string>("TwoFactorProviders") b.Property<string>("TwoFactorProviders")
.HasColumnType("TEXT"); .HasColumnType("TEXT");