diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs new file mode 100644 index 0000000000..9392c285e0 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -0,0 +1,101 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Queries; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Stripe; +using Stripe.Tax; + +namespace Bit.Commercial.Core.Billing.Providers.Queries; + +using static StripeConstants; +using SuspensionWarning = ProviderWarnings.SuspensionWarning; +using TaxIdWarning = ProviderWarnings.TaxIdWarning; + +public class GetProviderWarningsQuery( + ICurrentContext currentContext, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IGetProviderWarningsQuery +{ + public async Task Run(Provider provider) + { + var warnings = new ProviderWarnings(); + + var subscription = + await subscriberService.GetSubscription(provider, + new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); + + if (subscription == null) + { + return warnings; + } + + warnings.Suspension = GetSuspensionWarning(provider, subscription); + + warnings.TaxId = await GetTaxIdWarningAsync(provider, subscription.Customer); + + return warnings; + } + + private SuspensionWarning? GetSuspensionWarning( + Provider provider, + Subscription subscription) + { + if (provider.Enabled) + { + return null; + } + + return subscription.Status switch + { + SubscriptionStatus.Unpaid => currentContext.ProviderProviderAdmin(provider.Id) + ? new SuspensionWarning { Resolution = "add_payment_method", SubscriptionCancelsAt = subscription.CancelAt } + : new SuspensionWarning { Resolution = "contact_administrator" }, + _ => new SuspensionWarning { Resolution = "contact_support" } + }; + } + + private async Task GetTaxIdWarningAsync( + Provider provider, + Customer customer) + { + if (!currentContext.ProviderProviderAdmin(provider.Id)) + { + return null; + } + + // TODO: Potentially DRY this out with the GetOrganizationWarningsQuery + + // Get active and scheduled registrations + var registrations = (await Task.WhenAll( + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + .SelectMany(registrations => registrations.Data); + + // Find the matching registration for the customer + var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country); + + // If we're not registered in their country, we don't need a warning + if (registration == null) + { + return null; + } + + var taxId = customer.TaxIds.FirstOrDefault(); + + return taxId switch + { + // Customer's tax ID is missing + null => new TaxIdWarning { Type = "tax_id_missing" }, + // Not sure if this case is valid, but Stripe says this property is nullable + not null when taxId.Verification == null => null, + // Customer's tax ID is pending verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" }, + // Customer's tax ID failed verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" }, + _ => null + }; + } +} diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 34f49e0ccc..022045e64f 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Services; +using Bit.Commercial.Core.Billing.Providers.Queries; using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Providers.Queries; using Bit.Core.Billing.Providers.Services; using Microsoft.Extensions.DependencyInjection; @@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs new file mode 100644 index 0000000000..f199c44924 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -0,0 +1,523 @@ +using Bit.Commercial.Core.Billing.Providers.Queries; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Stripe.Tax; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing.Providers.Queries; + +using static StripeConstants; + +[SutProviderCustomize] +public class GetProviderWarningsQueryTests +{ + private static readonly string[] _requiredExpansions = ["customer.tax_ids"]; + + [Theory, BitAutoData] + public async Task Run_NoSubscription_NoWarnings( + Provider provider, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .ReturnsNull(); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension: null, + TaxId: null + }); + } + + [Theory, BitAutoData] + public async Task Run_ProviderEnabled_NoSuspensionWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.Suspension); + } + + [Theory, BitAutoData] + public async Task Run_Has_SuspensionWarning_AddPaymentMethod( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + var cancelAt = DateTime.UtcNow.AddDays(7); + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + CancelAt = cancelAt, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "add_payment_method" + }); + Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_Has_SuspensionWarning_ContactAdministrator( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "contact_administrator" + }); + Assert.Null(response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_Has_SuspensionWarning_ContactSupport( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Canceled, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "contact_support" + }); + } + + [Theory, BitAutoData] + public async Task Run_NotProviderAdmin_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NoTaxRegistrationForCountry_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdMissingWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_missing" + }); + } + + [Theory, BitAutoData] + public async Task Run_TaxIdVerificationIsNull_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId { Verification = null }] + }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdPendingVerificationWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Pending + } + }] + }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_pending_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdFailedVerificationWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Unverified + } + }] + }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_failed_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_TaxIdVerified_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Verified + } + }] + }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_MultipleRegistrations_MatchesCorrectCountry( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "DE" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Active)) + .Returns(new StripeList + { + Data = [ + new Registration { Country = "US" }, + new Registration { Country = "DE" }, + new Registration { Country = "FR" } + ] + }); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Scheduled)) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_missing" + }); + } + + [Theory, BitAutoData] + public async Task Run_CombinesBothWarningTypes( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + var cancelAt = DateTime.UtcNow.AddDays(5); + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + CancelAt = cancelAt, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "add_payment_method", + TaxId.Type: "tax_id_missing" + }); + Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs similarity index 100% rename from bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs similarity index 100% rename from bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs similarity index 100% rename from bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 4915e5ef8e..762b06db96 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -6,7 +6,6 @@ using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; @@ -28,7 +27,6 @@ public class OrganizationBillingController( ICurrentContext currentContext, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, - IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IPaymentService paymentService, IPricingClient pricingClient, ISubscriberService subscriberService, @@ -359,31 +357,6 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } - [HttpGet("warnings")] - public async Task GetWarningsAsync([FromRoute] Guid organizationId) - { - /* - * We'll keep these available at the User level because we're hiding any pertinent information, and - * we want to throw as few errors as possible since these are not core features. - */ - if (!await currentContext.OrganizationUser(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var warnings = await getOrganizationWarningsQuery.Run(organization); - - return TypedResults.Ok(warnings); - } - - [HttpPost("change-frequency")] [SelfHosted(NotSelfHostedOnly = true)] public async Task ChangePlanSubscriptionFrequencyAsync( diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index 429f2065f6..a85dfe11e1 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -1,9 +1,10 @@ -#nullable enable -using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requirements; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Utilities; @@ -21,6 +22,7 @@ public class OrganizationBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, IGetBillingAddressQuery getBillingAddressQuery, IGetCreditQuery getCreditQuery, + IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand, @@ -104,4 +106,14 @@ public class OrganizationBillingVNextController( var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode); return Handle(result); } + + [Authorize] + [HttpGet("warnings")] + [InjectOrganization] + public async Task GetWarningsAsync( + [BindNever] Organization organization) + { + var warnings = await getOrganizationWarningsQuery.Run(organization); + return TypedResults.Ok(warnings); + } } diff --git a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs index d0cc377245..b0b39eaf4a 100644 --- a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Providers.Queries; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -19,6 +20,7 @@ public class ProviderBillingVNextController( IGetBillingAddressQuery getBillingAddressQuery, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, + IGetProviderWarningsQuery getProviderWarningsQuery, IProviderService providerService, IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand, @@ -104,4 +106,13 @@ public class ProviderBillingVNextController( var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode); return Handle(result); } + + [HttpGet("warnings")] + [InjectProvider(ProviderUserType.ServiceUser)] + public async Task GetWarningsAsync( + [BindNever] Provider provider) + { + var warnings = await getProviderWarningsQuery.Run(provider); + return TypedResults.Ok(warnings); + } } diff --git a/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs b/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs index 4efdf0812a..9978e84f56 100644 --- a/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs +++ b/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization; using Bit.Core.Context; using Bit.Core.Enums; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 7b4cb3baed..2be88902c8 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -113,6 +113,21 @@ public static class StripeConstants public const string SpanishNIF = "es_cif"; } + public static class TaxIdVerificationStatus + { + public const string Pending = "pending"; + public const string Unavailable = "unavailable"; + public const string Unverified = "unverified"; + public const string Verified = "verified"; + } + + public static class TaxRegistrationStatus + { + public const string Active = "active"; + public const string Expired = "expired"; + public const string Scheduled = "scheduled"; + } + public static class ValidateTaxLocationTiming { public const string Deferred = "deferred"; diff --git a/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs b/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs index 4507c84083..cf386fb317 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs @@ -5,6 +5,7 @@ public record OrganizationWarnings public FreeTrialWarning? FreeTrial { get; set; } public InactiveSubscriptionWarning? InactiveSubscription { get; set; } public ResellerRenewalWarning? ResellerRenewal { get; set; } + public TaxIdWarning? TaxId { get; set; } public record FreeTrialWarning { @@ -39,4 +40,9 @@ public record OrganizationWarnings public required DateTime SuspensionDate { get; set; } } } + + public record TaxIdWarning + { + public required string Type { get; set; } + } } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index a46d7483e7..0b0cbd22c6 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -1,26 +1,25 @@ -// ReSharper disable InconsistentNaming - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; using Stripe; -using FreeTrialWarning = Bit.Core.Billing.Organizations.Models.OrganizationWarnings.FreeTrialWarning; -using InactiveSubscriptionWarning = - Bit.Core.Billing.Organizations.Models.OrganizationWarnings.InactiveSubscriptionWarning; -using ResellerRenewalWarning = - Bit.Core.Billing.Organizations.Models.OrganizationWarnings.ResellerRenewalWarning; +using Stripe.Tax; namespace Bit.Core.Billing.Organizations.Queries; using static StripeConstants; +using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning; +using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning; +using ResellerRenewalWarning = OrganizationWarnings.ResellerRenewalWarning; +using TaxIdWarning = OrganizationWarnings.TaxIdWarning; public interface IGetOrganizationWarningsQuery { @@ -38,29 +37,31 @@ public class GetOrganizationWarningsQuery( public async Task Run( Organization organization) { - var response = new OrganizationWarnings(); + var warnings = new OrganizationWarnings(); var subscription = await subscriberService.GetSubscription(organization, - new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] }); + new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] }); if (subscription == null) { - return response; + return warnings; } - response.FreeTrial = await GetFreeTrialWarning(organization, subscription); + warnings.FreeTrial = await GetFreeTrialWarningAsync(organization, subscription); var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); - response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription); + warnings.InactiveSubscription = await GetInactiveSubscriptionWarningAsync(organization, provider, subscription); - response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription); + warnings.ResellerRenewal = await GetResellerRenewalWarningAsync(provider, subscription); - return response; + warnings.TaxId = await GetTaxIdWarningAsync(organization, subscription.Customer, provider); + + return warnings; } - private async Task GetFreeTrialWarning( + private async Task GetFreeTrialWarningAsync( Organization organization, Subscription subscription) { @@ -81,7 +82,7 @@ public class GetOrganizationWarningsQuery( var customer = subscription.Customer; - var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization); + var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization); var hasPaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || @@ -101,66 +102,51 @@ public class GetOrganizationWarningsQuery( return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays }; } - private async Task GetInactiveSubscriptionWarning( + private async Task GetInactiveSubscriptionWarningAsync( Organization organization, Provider? provider, Subscription subscription) { + // If the organization is enabled or the subscription is active, don't return a warning. + if (organization.Enabled || subscription is not + { + Status: SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled + }) + { + return null; + } + + // If the organization is managed by a provider, return a warning asking them to contact the provider. + if (provider != null) + { + return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; + } + var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id); - switch (organization.Enabled) + /* If the organization is not managed by a provider and this user is the owner, return a warning based + on the subscription status. */ + if (isOrganizationOwner) { - // Member of an enabled, trialing organization. - case true when subscription.Status is SubscriptionStatus.Trialing: + return subscription.Status switch + { + SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning { - var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization); - - var hasPaymentMethod = - !string.IsNullOrEmpty(subscription.Customer.InvoiceSettings.DefaultPaymentMethodId) || - !string.IsNullOrEmpty(subscription.Customer.DefaultSourceId) || - hasUnverifiedBankAccount || - subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); - - // If this member is the owner and there's no payment method on file, ask them to add one. - return isOrganizationOwner && !hasPaymentMethod - ? new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" } - : null; - } - // Member of disabled and unpaid or canceled organization. - case false when subscription.Status is SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled: + Resolution = "add_payment_method" + }, + SubscriptionStatus.Canceled => new InactiveSubscriptionWarning { - // If the organization is managed by a provider, return a warning asking them to contact the provider. - if (provider != null) - { - return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; - } - - /* If the organization is not managed by a provider and this user is the owner, return an action warning based - on the subscription status. */ - if (isOrganizationOwner) - { - return subscription.Status switch - { - SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning - { - Resolution = "add_payment_method" - }, - SubscriptionStatus.Canceled => new InactiveSubscriptionWarning - { - Resolution = "resubscribe" - }, - _ => null - }; - } - - // Otherwise, this member is not the owner, and we need to ask them to contact the owner. - return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; - } - default: return null; + Resolution = "resubscribe" + }, + _ => null + }; } + + // Otherwise, return a warning asking them to contact the owner. + return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; } - private async Task GetResellerRenewalWarning( + private async Task GetResellerRenewalWarningAsync( Provider? provider, Subscription subscription) { @@ -241,7 +227,62 @@ public class GetOrganizationWarningsQuery( return null; } - private async Task HasUnverifiedBankAccount( + private async Task GetTaxIdWarningAsync( + Organization organization, + Customer customer, + Provider? provider) + { + var productTier = organization.PlanType.GetProductTier(); + + // Only business tier customers can have tax IDs + if (productTier is not ProductTierType.Teams and not ProductTierType.Enterprise) + { + return null; + } + + // Only an organization owner can update a tax ID + if (!await currentContext.OrganizationOwner(organization.Id)) + { + return null; + } + + if (provider != null) + { + return null; + } + + // Get active and scheduled registrations + var registrations = (await Task.WhenAll( + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + .SelectMany(registrations => registrations.Data); + + // Find the matching registration for the customer + var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country); + + // If we're not registered in their country, we don't need a warning + if (registration == null) + { + return null; + } + + var taxId = customer.TaxIds.FirstOrDefault(); + + return taxId switch + { + // Customer's tax ID is missing + null => new TaxIdWarning { Type = "tax_id_missing" }, + // Not sure if this case is valid, but Stripe says this property is nullable + not null when taxId.Verification == null => null, + // Customer's tax ID is pending verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" }, + // Customer's tax ID failed verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" }, + _ => null + }; + } + + private async Task HasUnverifiedBankAccountAsync( Organization organization) { var setupIntentId = await setupIntentCache.Get(organization.Id); diff --git a/src/Core/Billing/Providers/Models/ProviderWarnings.cs b/src/Core/Billing/Providers/Models/ProviderWarnings.cs new file mode 100644 index 0000000000..dd9d9be41c --- /dev/null +++ b/src/Core/Billing/Providers/Models/ProviderWarnings.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.Billing.Providers.Models; + +public class ProviderWarnings +{ + public SuspensionWarning? Suspension { get; set; } + public TaxIdWarning? TaxId { get; set; } + + public record SuspensionWarning + { + public required string Resolution { get; set; } + public DateTime? SubscriptionCancelsAt { get; set; } + } + + public record TaxIdWarning + { + public required string Type { get; set; } + } +} diff --git a/src/Core/Billing/Providers/Queries/IGetProviderWarningsQuery.cs b/src/Core/Billing/Providers/Queries/IGetProviderWarningsQuery.cs new file mode 100644 index 0000000000..ed868a8475 --- /dev/null +++ b/src/Core/Billing/Providers/Queries/IGetProviderWarningsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Providers.Models; + +namespace Bit.Core.Billing.Providers.Queries; + +public interface IGetProviderWarningsQuery +{ + Task Run(Provider provider); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7cedf42b5b..d4a6fdb31d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -161,6 +161,7 @@ public static class FeatureFlagKeys public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe"; public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; + public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 2b2bf8d825..8a41263956 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -48,6 +48,7 @@ public interface IStripeAdapter Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); Task TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null); + Task> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null); Task> ChargeListAsync(Stripe.ChargeListOptions options); Task RefundCreateAsync(Stripe.RefundCreateOptions options); Task CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 9315d92ebe..03d1776e90 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -22,6 +22,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.SetupIntentService _setupIntentService; private readonly Stripe.TestHelpers.TestClockService _testClockService; private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; + private readonly Stripe.Tax.RegistrationService _taxRegistrationService; public StripeAdapter() { @@ -39,6 +40,7 @@ public class StripeAdapter : IStripeAdapter _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); + _taxRegistrationService = new Stripe.Tax.RegistrationService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) @@ -208,6 +210,11 @@ public class StripeAdapter : IStripeAdapter return _taxIdService.DeleteAsync(customerId, taxIdId); } + public Task> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null) + { + return _taxRegistrationService.ListAsync(options); + } + public Task> ChargeListAsync(Stripe.ChargeListOptions options) { return _chargeService.ListAsync(options); diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index 54c982192b..c22cc239d8 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -21,7 +21,7 @@ namespace Bit.Core.Test.Billing.Organizations.Queries; [SutProviderCustomize] public class GetOrganizationWarningsQueryTests { - private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"]; + private static readonly string[] _requiredExpansions = ["customer.tax_ids", "latest_invoice", "test_clock"]; [Theory, BitAutoData] public async Task Run_NoSubscription_NoWarnings( @@ -130,7 +130,7 @@ public class GetOrganizationWarningsQueryTests } [Theory, BitAutoData] - public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethodOptionalTrial( + public async Task Run_OrganizationEnabled_NoInactiveSubscriptionWarning( Organization organization, SutProvider sutProvider) { @@ -142,7 +142,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Trialing, + Status = StripeConstants.SubscriptionStatus.Unpaid, Customer = new Customer { InvoiceSettings = new CustomerInvoiceSettings(), @@ -151,14 +151,10 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns((string?)null); var response = await sutProvider.Sut.Run(organization); - Assert.True(response is - { - InactiveSubscription.Resolution: "add_payment_method_optional_trial" - }); + Assert.Null(response.InactiveSubscription); } [Theory, BitAutoData]