diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs new file mode 100644 index 000000000..d26de8111 --- /dev/null +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -0,0 +1,27 @@ +using Bit.Api.Billing.Models.Responses; +using Bit.Core.Billing.Queries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Route("organizations/{organizationId:guid}/billing")] +[Authorize("Application")] +public class OrganizationBillingController( + IOrganizationBillingQueries organizationBillingQueries) : Controller +{ + [HttpGet("metadata")] + public async Task GetMetadataAsync([FromRoute] Guid organizationId) + { + var metadata = await organizationBillingQueries.GetMetadata(organizationId); + + if (metadata == null) + { + return TypedResults.NotFound(); + } + + var response = OrganizationMetadataResponse.From(metadata); + + return TypedResults.Ok(response); + } +} diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs new file mode 100644 index 000000000..2323280d4 --- /dev/null +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -0,0 +1,10 @@ +using Bit.Core.Billing.Models; + +namespace Bit.Api.Billing.Models.Responses; + +public record OrganizationMetadataResponse( + bool IsOnSecretsManagerStandalone) +{ + public static OrganizationMetadataResponse From(OrganizationMetadataDTO metadataDTO) + => new(metadataDTO.IsOnSecretsManagerStandalone); +} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 9fd4e8489..71efc8a71 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -16,6 +16,11 @@ public static class StripeConstants public const string SendInvoice = "send_invoice"; } + public static class CouponIDs + { + public const string SecretsManagerStandalone = "sm-standalone"; + } + public static class ProrationBehavior { public const string AlwaysInvoice = "always_invoice"; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 2d802dced..28c3ace06 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions public static void AddBillingOperations(this IServiceCollection services) { // Queries + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/Billing/Models/OrganizationMetadataDTO.cs b/src/Core/Billing/Models/OrganizationMetadataDTO.cs new file mode 100644 index 000000000..fc395e889 --- /dev/null +++ b/src/Core/Billing/Models/OrganizationMetadataDTO.cs @@ -0,0 +1,4 @@ +namespace Bit.Core.Billing.Models; + +public record OrganizationMetadataDTO( + bool IsOnSecretsManagerStandalone); diff --git a/src/Core/Billing/Queries/IOrganizationBillingQueries.cs b/src/Core/Billing/Queries/IOrganizationBillingQueries.cs new file mode 100644 index 000000000..f0d3434c5 --- /dev/null +++ b/src/Core/Billing/Queries/IOrganizationBillingQueries.cs @@ -0,0 +1,8 @@ +using Bit.Core.Billing.Models; + +namespace Bit.Core.Billing.Queries; + +public interface IOrganizationBillingQueries +{ + Task GetMetadata(Guid organizationId); +} diff --git a/src/Core/Billing/Queries/Implementations/OrganizationBillingQueries.cs b/src/Core/Billing/Queries/Implementations/OrganizationBillingQueries.cs new file mode 100644 index 000000000..4cf5f9691 --- /dev/null +++ b/src/Core/Billing/Queries/Implementations/OrganizationBillingQueries.cs @@ -0,0 +1,65 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Stripe; + +namespace Bit.Core.Billing.Queries.Implementations; + +public class OrganizationBillingQueries( + IOrganizationRepository organizationRepository, + ISubscriberQueries subscriberQueries) : IOrganizationBillingQueries +{ + public async Task GetMetadata(Guid organizationId) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return null; + } + + var customer = await subscriberQueries.GetCustomer(organization, new CustomerGetOptions + { + Expand = ["discount.coupon.applies_to"] + }); + + var subscription = await subscriberQueries.GetSubscription(organization); + + if (customer == null || subscription == null) + { + return null; + } + + var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription); + + return new OrganizationMetadataDTO(isOnSecretsManagerStandalone); + } + + private static bool IsOnSecretsManagerStandalone( + Organization organization, + Customer customer, + Subscription subscription) + { + var plan = StaticStore.GetPlan(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(); + } +} diff --git a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs new file mode 100644 index 000000000..8e495aa28 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs @@ -0,0 +1,43 @@ +using Bit.Api.Billing.Controllers; +using Bit.Api.Billing.Models.Responses; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http.HttpResults; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers; + +[ControllerCustomize(typeof(OrganizationBillingController))] +[SutProviderCustomize] +public class OrganizationBillingControllerTests +{ + [Theory, BitAutoData] + public async Task GetMetadataAsync_MetadataNull_NotFound( + Guid organizationId, + SutProvider sutProvider) + { + var result = await sutProvider.Sut.GetMetadataAsync(organizationId); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetMetadataAsync_OK( + Guid organizationId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetMetadata(organizationId) + .Returns(new OrganizationMetadataDTO(true)); + + var result = await sutProvider.Sut.GetMetadataAsync(organizationId); + + Assert.IsType>(result); + + var organizationMetadataResponse = ((Ok)result).Value; + + Assert.True(organizationMetadataResponse.IsOnSecretsManagerStandalone); + } +} diff --git a/test/Core.Test/Billing/Queries/OrganizationBillingQueriesTests.cs b/test/Core.Test/Billing/Queries/OrganizationBillingQueriesTests.cs new file mode 100644 index 000000000..f80c3c326 --- /dev/null +++ b/test/Core.Test/Billing/Queries/OrganizationBillingQueriesTests.cs @@ -0,0 +1,105 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Queries; +using Bit.Core.Billing.Queries.Implementations; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Queries; + +[SutProviderCustomize] +public class OrganizationBillingQueriesTests +{ + #region GetMetadata + [Theory, BitAutoData] + public async Task GetMetadata_OrganizationNull_ReturnsNull( + Guid organizationId, + SutProvider sutProvider) + { + var metadata = await sutProvider.Sut.GetMetadata(organizationId); + + Assert.Null(metadata); + } + + [Theory, BitAutoData] + public async Task GetMetadata_CustomerNull_ReturnsNull( + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var metadata = await sutProvider.Sut.GetMetadata(organizationId); + + Assert.Null(metadata); + } + + [Theory, BitAutoData] + public async Task GetMetadata_SubscriptionNull_ReturnsNull( + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency().GetCustomer(organization).Returns(new Customer()); + + var metadata = await sutProvider.Sut.GetMetadata(organizationId); + + Assert.Null(metadata); + } + + [Theory, BitAutoData] + public async Task GetMetadata_Succeeds( + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var subscriberQueries = sutProvider.GetDependency(); + + subscriberQueries + .GetCustomer(organization, Arg.Is(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to")) + .Returns(new Customer + { + Discount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = ["product_id"] + } + } + } + }); + + subscriberQueries.GetSubscription(organization).Returns(new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Plan = new Plan + { + ProductId = "product_id" + } + } + ] + } + }); + + var metadata = await sutProvider.Sut.GetMetadata(organizationId); + + Assert.True(metadata.IsOnSecretsManagerStandalone); + } + #endregion +}