Browse Source
* 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 formatpull/5938/merge
12 changed files with 498 additions and 97 deletions
@ -1,31 +0,0 @@
@@ -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); |
||||
} |
||||
@ -1,28 +1,10 @@
@@ -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); |
||||
} |
||||
|
||||
@ -0,0 +1,95 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,369 @@
@@ -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); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue