1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-25379] Refactor org metadata (#6418)

* ignore serena

* removing unused properties from org metadata

* removing further properties that can already be fetched on the client side using available data

* new vnext endpoint for org metadata plus caching metadata first pass

including new feature flag

# Conflicts:
#	src/Core/Constants.cs

* [PM-25379] decided against cache and new query shouldn't use the service

* pr feedback

removing unneeded response model

* run dotnet format
This commit is contained in:
Kyle Denney
2025-10-09 15:50:07 -05:00
committed by GitHub
parent 34f5ffd981
commit 3bef57259d
12 changed files with 498 additions and 97 deletions

1
.gitignore vendored
View File

@@ -231,3 +231,4 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json
/api.json
/api.public.json
.serena/

View File

@@ -38,9 +38,7 @@ public class OrganizationBillingController(
return Error.NotFound();
}
var response = OrganizationMetadataResponse.From(metadata);
return TypedResults.Ok(response);
return TypedResults.Ok(metadata);
}
[HttpGet("history")]

View File

@@ -4,6 +4,7 @@ using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Subscriptions;
using Bit.Api.Billing.Models.Requirements;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Organizations.Queries;
@@ -25,6 +26,7 @@ public class OrganizationBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetOrganizationMetadataQuery getOrganizationMetadataQuery,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IRestartSubscriptionCommand restartSubscriptionCommand,
@@ -113,6 +115,23 @@ public class OrganizationBillingVNextController(
return Handle(result);
}
[Authorize<MemberOrProviderRequirement>]
[HttpGet("metadata")]
[RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)]
[InjectOrganization]
public async Task<IResult> GetMetadataAsync(
[BindNever] Organization organization)
{
var metadata = await getOrganizationMetadataQuery.Run(organization);
if (metadata == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(metadata);
}
[Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")]
[InjectOrganization]

View File

@@ -1,31 +0,0 @@
using Bit.Core.Billing.Organizations.Models;
namespace Bit.Api.Billing.Models.Responses;
public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid,
bool HasSubscription,
bool HasOpenInvoice,
bool IsSubscriptionCanceled,
DateTime? InvoiceDueDate,
DateTime? InvoiceCreatedDate,
DateTime? SubPeriodEndDate,
int OrganizationOccupiedSeats)
{
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new(
metadata.IsEligibleForSelfHost,
metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid,
metadata.HasSubscription,
metadata.HasOpenInvoice,
metadata.IsSubscriptionCanceled,
metadata.InvoiceDueDate,
metadata.InvoiceCreatedDate,
metadata.SubPeriodEndDate,
metadata.OrganizationOccupiedSeats);
}

View File

@@ -31,6 +31,7 @@ public static class ServiceCollectionExtensions
services.AddPaymentOperations();
services.AddOrganizationLicenseCommandsQueries();
services.AddPremiumCommands();
services.AddTransient<IGetOrganizationMetadataQuery, GetOrganizationMetadataQuery>();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();

View File

@@ -1,28 +1,10 @@
namespace Bit.Core.Billing.Organizations.Models;
public record OrganizationMetadata(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid,
bool HasSubscription,
bool HasOpenInvoice,
bool IsSubscriptionCanceled,
DateTime? InvoiceDueDate,
DateTime? InvoiceCreatedDate,
DateTime? SubPeriodEndDate,
int OrganizationOccupiedSeats)
{
public static OrganizationMetadata Default => new OrganizationMetadata(
false,
false,
false,
false,
false,
false,
false,
null,
null,
null,
0);
}

View File

@@ -0,0 +1,95 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Stripe;
namespace Bit.Core.Billing.Organizations.Queries;
public interface IGetOrganizationMetadataQuery
{
Task<OrganizationMetadata?> Run(Organization organization);
}
public class GetOrganizationMetadataQuery(
IGlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
ISubscriberService subscriberService) : IGetOrganizationMetadataQuery
{
public async Task<OrganizationMetadata?> Run(Organization organization)
{
if (organization == null)
{
return null;
}
if (globalSettings.SelfHosted)
{
return OrganizationMetadata.Default;
}
var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return OrganizationMetadata.Default with
{
OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
var customer = await subscriberService.GetCustomer(organization,
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
var subscription = await subscriberService.GetSubscription(organization);
if (customer == null || subscription == null)
{
return OrganizationMetadata.Default with
{
OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription);
return new OrganizationMetadata(
isOnSecretsManagerStandalone,
orgOccupiedSeats.Total);
}
private async Task<bool> IsOnSecretsManagerStandalone(
Organization organization,
Customer? customer,
Subscription? subscription)
{
if (customer == null || subscription == null)
{
return false;
}
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.SupportsSecretsManager)
{
return false;
}
var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone;
if (!hasCoupon)
{
return false;
}
var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId);
var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products;
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
}
}

View File

@@ -74,16 +74,12 @@ public class OrganizationBillingService(
return OrganizationMetadata.Default;
}
var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return OrganizationMetadata.Default with
{
IsEligibleForSelfHost = isEligibleForSelfHost,
IsManaged = isManaged,
OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
@@ -97,28 +93,14 @@ public class OrganizationBillingService(
{
return OrganizationMetadata.Default with
{
IsEligibleForSelfHost = isEligibleForSelfHost,
IsManaged = isManaged
OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription);
var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId)
? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions())
: null;
return new OrganizationMetadata(
isEligibleForSelfHost,
isManaged,
isOnSecretsManagerStandalone,
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid,
true,
invoice?.Status == StripeConstants.InvoiceStatus.Open,
subscription.Status == StripeConstants.SubscriptionStatus.Canceled,
invoice?.DueDate,
invoice?.Created,
subscription.CurrentPeriodEnd,
orgOccupiedSeats.Total);
}
@@ -536,16 +518,6 @@ public class OrganizationBillingService(
return customer;
}
private async Task<bool> IsEligibleForSelfHostAsync(
Organization organization)
{
var plans = await pricingClient.ListPlans();
var eligibleSelfHostPlans = plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type);
return eligibleSelfHostPlans.Contains(organization.PlanType);
}
private async Task<bool> IsOnSecretsManagerStandalone(
Organization organization,
Customer? customer,

View File

@@ -179,6 +179,7 @@ public static class FeatureFlagKeys
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure";
public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog";
public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button";
public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog";

View File

@@ -1,5 +1,4 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Responses;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
@@ -53,19 +52,16 @@ public class OrganizationBillingControllerTests
{
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(organizationId).Returns(true);
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId)
.Returns(new OrganizationMetadata(true, true, true, true, true, true, true, null, null, null, 0));
.Returns(new OrganizationMetadata(true, 10));
var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
Assert.IsType<Ok<OrganizationMetadataResponse>>(result);
Assert.IsType<Ok<OrganizationMetadata>>(result);
var response = ((Ok<OrganizationMetadataResponse>)result).Value;
var response = ((Ok<OrganizationMetadata>)result).Value;
Assert.True(response.IsEligibleForSelfHost);
Assert.True(response.IsManaged);
Assert.True(response.IsOnSecretsManagerStandalone);
Assert.True(response.IsSubscriptionUnpaid);
Assert.True(response.HasSubscription);
Assert.Equal(10, response.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]

View File

@@ -0,0 +1,369 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Organizations.Queries;
[SutProviderCustomize]
public class GetOrganizationMetadataQueryTests
{
[Theory, BitAutoData]
public async Task Run_NullOrganization_ReturnsNull(
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
var result = await sutProvider.Sut.Run(null);
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task Run_SelfHosted_ReturnsDefault(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
var result = await sutProvider.Sut.Run(organization);
Assert.Equal(OrganizationMetadata.Default, result);
}
[Theory, BitAutoData]
public async Task Run_NoGatewaySubscriptionId_ReturnsDefaultWithOccupiedSeats(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = null;
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 10, Sponsored = 0 });
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(10, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_NullCustomer_ReturnsDefaultWithOccupiedSeats(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(5, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_NullSubscription_ReturnsDefaultWithOccupiedSeats(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
var customer = new Customer();
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(7, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_WithSecretsManagerStandaloneCoupon_ReturnsMetadataWithFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.EnterpriseAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = productId
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.True(result.IsOnSecretsManagerStandalone);
Assert.Equal(15, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_WithoutSecretsManagerStandaloneCoupon_ReturnsMetadataWithoutFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.TeamsAnnually;
var customer = new Customer
{
Discount = null
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = "product_123"
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(20, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_CouponDoesNotApplyToSubscriptionProducts_ReturnsFalseForStandaloneFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.EnterpriseAnnually;
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["different_product_id"]
}
}
}
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = "product_123"
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(12, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_PlanDoesNotSupportSecretsManager_ReturnsFalseForStandaloneFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.FamiliesAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = productId
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(8, result.OrganizationOccupiedSeats);
}
}

View File

@@ -96,6 +96,10 @@ public class OrganizationBillingServiceTests
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 });
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
// Set up subscriber service to return null for customer
@@ -110,13 +114,7 @@ public class OrganizationBillingServiceTests
Assert.NotNull(metadata);
Assert.False(metadata!.IsOnSecretsManagerStandalone);
Assert.False(metadata.HasSubscription);
Assert.False(metadata.IsSubscriptionUnpaid);
Assert.False(metadata.HasOpenInvoice);
Assert.False(metadata.IsSubscriptionCanceled);
Assert.Null(metadata.InvoiceDueDate);
Assert.Null(metadata.InvoiceCreatedDate);
Assert.Null(metadata.SubPeriodEndDate);
Assert.Equal(1, metadata.OrganizationOccupiedSeats);
}
#endregion