Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,4 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json
/api.json
/api.public.json
.serena/
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Subscriptions;
using Bit.Api.Billing.Models.Requirements;
using Bit.Api.Billing.Models.Responses;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Organizations.Queries;
Expand All @@ -25,6 +27,7 @@ public class OrganizationBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetOrganizationMetadataQuery getOrganizationMetadataQuery,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IRestartSubscriptionCommand restartSubscriptionCommand,
Expand Down Expand Up @@ -113,6 +116,24 @@ public async Task<IResult> RestartSubscriptionAsync(
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();
}

var response = OrganizationMetadataResponse.From(metadata);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ I'm fine with removing this layer of response models when they do nothing but copy the Core models if you want to. Your call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion; thanks! Especially now that it's only 2 properties, this class seems very superfluous now 😆 Removed in ddbf084

return TypedResults.Ok(response);
}

[Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")]
[InjectOrganization]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,11 @@
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddPaymentOperations();
services.AddOrganizationLicenseCommandsQueries();
services.AddPremiumCommands();
services.AddTransient<IGetOrganizationMetadataQuery, GetOrganizationMetadataQuery>();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
Expand Down
18 changes: 0 additions & 18 deletions src/Core/Billing/Organizations/Models/OrganizationMetadata.cs
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,12 @@ public async Task Finalize(OrganizationSale sale)
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
};
}
Expand All @@ -95,28 +91,14 @@ public async Task Finalize(OrganizationSale sale)
{
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);
}

Expand Down Expand Up @@ -534,16 +516,6 @@ ProductTierType.TeamsStarter or
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,
Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ public static class FeatureFlagKeys
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
public const string PM23385_UseNewPremiumFlow = "pm-23385-use-new-premium-flow";
public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure";
public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog";

/* Key Management Team */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,16 @@ public async Task GetMetadataAsync_OK(
{
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);

var response = ((Ok<OrganizationMetadataResponse>)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]
Expand Down
Loading
Loading