From 95139def0f2aa5865bedd66d605903e4030cae7f Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 12 Jan 2024 10:38:47 -0500 Subject: [PATCH] [AC-1758] Implement `RemoveOrganizationFromProviderCommand` (#3515) * Add RemovePaymentMethod to StripePaymentService * Add SendProviderUpdatePaymentMethod to HandlebarsMailService * Add RemoveOrganizationFromProviderCommand * Use RemoveOrganizationFromProviderCommand in ProviderOrganizationController * Remove RemoveOrganizationAsync from ProviderService * Add RemoveOrganizationFromProviderCommandTests * PR review feedback and refactoring * Remove RemovePaymentMethod from StripePaymentService * Review feedback * Add Organization RisksSubscriptionFailure endpoint * fix build error * Review feedback * [AC-1359] Bitwarden Portal Unlink Provider Buttons (#3588) * Added ability to unlink organization from provider from provider edit page * Refreshing provider edit page after removing an org * Added button to organization to remove the org from the provider * Updated based on product feedback * Removed organization name from alert message * Temporary logging * Remove coupon from Stripe org after disconnected from MSP * Updated test * Change payment terms on org disconnect from MSP * Set Stripe account email to new billing email * Remove logging --------- Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Co-authored-by: Conner Turnbull --- .../RemoveOrganizationFromProviderCommand.cs | 98 +++++ .../AdminConsole/Services/ProviderService.cs | 17 - .../Utilities/ServiceCollectionExtensions.cs | 1 + ...oveOrganizationFromProviderCommandTests.cs | 132 +++++++ .../Services/ProviderServiceTests.cs | 59 --- .../Controllers/OrganizationsController.cs | 45 ++- .../ProviderOrganizationsController.cs | 67 ++++ src/Admin/Startup.cs | 2 + src/Admin/Views/Organizations/Edit.cshtml | 12 +- .../Views/Providers/Organizations.cshtml | 26 +- .../_ProviderOrganizationScripts.cshtml | 21 + .../Shared/_OrganizationFormScripts.cshtml | 20 + .../Controllers/OrganizationsController.cs | 21 +- .../ProviderOrganizationsController.cs | 46 ++- ...onRisksSubscriptionFailureResponseModel.cs | 17 + src/Api/Startup.cs | 2 + .../IRemoveOrganizationFromProviderCommand.cs | 12 + .../AdminConsole/Services/IProviderService.cs | 1 - .../Commands/IRemovePaymentMethodCommand.cs | 8 + .../RemovePaymentMethodCommand.cs | 140 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 14 + .../ProviderUpdatePaymentMethod.html.hbs | 27 ++ .../ProviderUpdatePaymentMethod.text.hbs | 7 + .../ProviderUpdatePaymentMethodViewModel.cs | 11 + src/Core/Services/IMailService.cs | 5 + src/Core/Services/IPaymentService.cs | 1 + src/Core/Services/IStripeAdapter.cs | 1 + .../Implementations/HandlebarsMailService.cs | 24 ++ .../Services/Implementations/StripeAdapter.cs | 3 + .../Implementations/StripePaymentService.cs | 17 + .../NoopImplementations/NoopMailService.cs | 3 + .../Repositories/OrganizationRepository.cs | 13 +- .../Repositories/OrganizationRepository.cs | 16 +- .../OrganizationsControllerTests.cs | 31 +- .../RemovePaymentMethodCommandTests.cs | 367 ++++++++++++++++++ 35 files changed, 1168 insertions(+), 119 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs create mode 100644 src/Admin/Controllers/ProviderOrganizationsController.cs create mode 100644 src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs create mode 100644 src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs create mode 100644 src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs create mode 100644 src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs create mode 100644 src/Core/Billing/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs create mode 100644 src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs create mode 100644 test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs new file mode 100644 index 000000000..778cd62c2 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -0,0 +1,98 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Providers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Commercial.Core.AdminConsole.Providers; + +public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand +{ + private readonly IEventService _eventService; + private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IStripeAdapter _stripeAdapter; + + public RemoveOrganizationFromProviderCommand( + IEventService eventService, + ILogger logger, + IMailService mailService, + IOrganizationRepository organizationRepository, + IOrganizationService organizationService, + IProviderOrganizationRepository providerOrganizationRepository, + IStripeAdapter stripeAdapter) + { + _eventService = eventService; + _logger = logger; + _mailService = mailService; + _organizationRepository = organizationRepository; + _organizationService = organizationService; + _providerOrganizationRepository = providerOrganizationRepository; + _stripeAdapter = stripeAdapter; + } + + public async Task RemoveOrganizationFromProvider( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization) + { + if (provider == null || + providerOrganization == null || + organization == null || + providerOrganization.ProviderId != provider.Id) + { + throw new BadRequestException("Failed to remove organization. Please contact support."); + } + + if (!await _organizationService.HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false)) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + var organizationOwnerEmails = + (await _organizationRepository.GetOwnerEmailAddressesById(organization.Id)).ToList(); + + organization.BillingEmail = organizationOwnerEmails.MinBy(email => email); + + await _organizationRepository.ReplaceAsync(organization); + + var customerUpdateOptions = new CustomerUpdateOptions + { + Coupon = string.Empty, + Email = organization.BillingEmail + }; + + await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions); + + var subscriptionUpdateOptions = new SubscriptionUpdateOptions + { + CollectionMethod = "send_invoice", + DaysUntilDue = 30 + }; + + await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); + + await _mailService.SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + organizationOwnerEmails); + + await _providerOrganizationRepository.DeleteAsync(providerOrganization); + + await _eventService.LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } +} diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index c8b64da19..f9049de07 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -527,23 +527,6 @@ public class ProviderService : IProviderService return providerOrganization; } - public async Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId) - { - var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(providerOrganizationId); - if (providerOrganization == null || providerOrganization.ProviderId != providerId) - { - throw new BadRequestException("Invalid organization."); - } - - if (!await _organizationService.HasConfirmedOwnersExceptAsync(providerOrganization.OrganizationId, new Guid[] { }, includeProvider: false)) - { - throw new BadRequestException("Organization needs to have at least one confirmed owner."); - } - - await _providerOrganizationRepository.DeleteAsync(providerOrganization); - await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - } - public async Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId) { var provider = await _providerRepository.GetByIdAsync(providerId); diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 09788406d..53c089f9f 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -12,5 +12,6 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs new file mode 100644 index 000000000..7148bcb17 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -0,0 +1,132 @@ +using Bit.Commercial.Core.AdminConsole.Providers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures; + +[SutProviderCustomize] +public class RemoveOrganizationFromProviderCommandTests +{ + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoProvider_BadRequest( + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(null, null, null)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoProviderOrganization_BadRequest( + Provider provider, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, null, null)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoOrganization_BadRequest( + Provider provider, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider( + provider, providerOrganization, null)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_MismatchedProviderOrganization_BadRequest( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoConfirmedOwners_BadRequest( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + providerOrganization.ProviderId = provider.Id; + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization)); + + Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + providerOrganization.ProviderId = provider.Id; + + var organizationRepository = sutProvider.GetDependency(); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false) + .Returns(true); + + var organizationOwnerEmails = new List { "a@gmail.com", "b@gmail.com" }; + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails); + + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com")); + + var stripeAdapter = sutProvider.GetDependency(); + + await stripeAdapter.Received(1).CustomerUpdateAsync( + organization.GatewayCustomerId, Arg.Is( + options => options.Coupon == string.Empty && options.Email == "a@gmail.com")); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync( + organization.GatewaySubscriptionId, Arg.Is( + options => options.CollectionMethod == "send_invoice" && options.DaysUntilDue == 30)); + + await sutProvider.GetDependency().Received(1).SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + Arg.Is>(emails => emails.Contains("a@gmail.com") && emails.Contains("b@gmail.com"))); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1).LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 24167e714..eea0ac53f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -541,65 +541,6 @@ public class ProviderServiceTests t.First().Item2 == null)); } - [Theory, BitAutoData] - public async Task RemoveOrganization_ProviderOrganizationIsInvalid_Throws(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - sutProvider.GetDependency().GetByIdAsync(providerOrganization.Id) - .ReturnsNull(); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id)); - Assert.Equal("Invalid organization.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveOrganization_ProviderOrganizationBelongsToWrongProvider_Throws(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - sutProvider.GetDependency().GetByIdAsync(providerOrganization.Id) - .Returns(providerOrganization); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id)); - Assert.Equal("Invalid organization.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveOrganization_HasNoOwners_Throws(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - providerOrganization.ProviderId = provider.Id; - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - sutProvider.GetDependency().GetByIdAsync(providerOrganization.Id) - .Returns(providerOrganization); - sutProvider.GetDependency().HasConfirmedOwnersExceptAsync(default, default, default) - .ReturnsForAnyArgs(false); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id)); - Assert.Equal("Organization needs to have at least one confirmed owner.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveOrganization_Success(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - providerOrganization.ProviderId = provider.Id; - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - var providerOrganizationRepository = sutProvider.GetDependency(); - providerOrganizationRepository.GetByIdAsync(providerOrganization.Id).Returns(providerOrganization); - sutProvider.GetDependency().HasConfirmedOwnersExceptAsync(default, default, default) - .ReturnsForAnyArgs(true); - - await sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id); - await providerOrganizationRepository.Received().DeleteAsync(providerOrganization); - await sutProvider.GetDependency().Received() - .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - } - [Theory, BitAutoData] public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key, SutProvider sutProvider) diff --git a/src/Admin/Controllers/OrganizationsController.cs b/src/Admin/Controllers/OrganizationsController.cs index aebdcefe4..d665ebdde 100644 --- a/src/Admin/Controllers/OrganizationsController.cs +++ b/src/Admin/Controllers/OrganizationsController.cs @@ -3,7 +3,9 @@ using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -48,6 +50,9 @@ public class OrganizationsController : Controller private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; + private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; public OrganizationsController( IOrganizationService organizationService, @@ -71,7 +76,10 @@ public class OrganizationsController : Controller ICurrentContext currentContext, ISecretRepository secretRepository, IProjectRepository projectRepository, - IServiceAccountRepository serviceAccountRepository) + IServiceAccountRepository serviceAccountRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, + IRemovePaymentMethodCommand removePaymentMethodCommand) { _organizationService = organizationService; _organizationRepository = organizationRepository; @@ -95,6 +103,9 @@ public class OrganizationsController : Controller _secretRepository = secretRepository; _projectRepository = projectRepository; _serviceAccountRepository = serviceAccountRepository; + _providerOrganizationRepository = providerOrganizationRepository; + _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; + _removePaymentMethodCommand = removePaymentMethodCommand; } [RequirePermission(Permission.Org_List_View)] @@ -286,6 +297,38 @@ public class OrganizationsController : Controller return Json(null); } + + [HttpPost] + [RequirePermission(Permission.Provider_Edit)] + public async Task UnlinkOrganizationFromProviderAsync(Guid id) + { + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization is null) + { + return RedirectToAction("Index"); + } + + var provider = await _providerRepository.GetByOrganizationIdAsync(id); + if (provider is null) + { + return RedirectToAction("Edit", new { id }); + } + + var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(id); + if (providerOrganization is null) + { + return RedirectToAction("Edit", new { id }); + } + + await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( + provider, + providerOrganization, + organization); + + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + + return Json(null); + } private async Task GetOrganization(Guid id, OrganizationEditModel model) { var organization = await _organizationRepository.GetByIdAsync(id); diff --git a/src/Admin/Controllers/ProviderOrganizationsController.cs b/src/Admin/Controllers/ProviderOrganizationsController.cs new file mode 100644 index 000000000..e21e1297f --- /dev/null +++ b/src/Admin/Controllers/ProviderOrganizationsController.cs @@ -0,0 +1,67 @@ +using Bit.Admin.Enums; +using Bit.Admin.Utilities; +using Bit.Core.AdminConsole.Providers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Admin.Controllers; + +[Authorize] +[SelfHosted(NotSelfHostedOnly = true)] +public class ProviderOrganizationsController : Controller +{ + private readonly IProviderRepository _providerRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; + private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; + + public ProviderOrganizationsController(IProviderRepository providerRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IOrganizationRepository organizationRepository, + IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, + IRemovePaymentMethodCommand removePaymentMethodCommand) + { + _providerRepository = providerRepository; + _providerOrganizationRepository = providerOrganizationRepository; + _organizationRepository = organizationRepository; + _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; + _removePaymentMethodCommand = removePaymentMethodCommand; + } + + [HttpPost] + [RequirePermission(Permission.Provider_Edit)] + public async Task DeleteAsync(Guid providerId, Guid id) + { + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider is null) + { + return RedirectToAction("Index", "Providers"); + } + + var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id); + if (providerOrganization is null) + { + return RedirectToAction("View", "Providers", new { id = providerId }); + } + + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization == null) + { + return RedirectToAction("View", "Providers", new { id = providerId }); + } + + await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( + provider, + providerOrganization, + organization); + + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + + return Json(null); + } +} diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index a10cd4d2d..4c2bfdb7d 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -9,6 +9,7 @@ using Stripe; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Admin.Services; +using Bit.Core.Billing.Extensions; #if !OSS using Bit.Commercial.Core.Utilities; @@ -87,6 +88,7 @@ public class Startup services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); services.AddScoped(); + services.AddBillingCommands(); #if OSS services.AddOosServices(); diff --git a/src/Admin/Views/Organizations/Edit.cshtml b/src/Admin/Views/Organizations/Edit.cshtml index e3f6d5090..ad4e4f848 100644 --- a/src/Admin/Views/Organizations/Edit.cshtml +++ b/src/Admin/Views/Organizations/Edit.cshtml @@ -8,6 +8,7 @@ var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View); var canInitiateTrial = AccessControlService.UserHasPermission(Permission.Org_InitiateTrial); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); + var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); } @section Scripts { @@ -81,7 +82,7 @@
- @if (canInitiateTrial) + @if (canInitiateTrial && Model.Provider is null) { } + @if (canUnlinkFromProvider && Model.Provider is not null) + { + + } @if (canDelete) {
Provider Organizations
@@ -32,26 +40,28 @@ } else { - @foreach (var org in Model.ProviderOrganizations) + @foreach (var providerOrganization in Model.ProviderOrganizations) { - @org.OrganizationName + @providerOrganization.OrganizationName - @org.Status + @providerOrganization.Status
- @if (org.Status == OrganizationStatusType.Pending) + @if (canUnlinkFromProvider) { - - + + Unlink provider } - else + @if (providerOrganization.Status == OrganizationStatusType.Pending) { - + + Resend invitation + }
diff --git a/src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml b/src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml new file mode 100644 index 000000000..b8fefb4c1 --- /dev/null +++ b/src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml @@ -0,0 +1,21 @@ + diff --git a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml index fc527750a..85d62f6b0 100644 --- a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml @@ -113,6 +113,26 @@ } } + function unlinkProvider(id) { + if (confirm('Are you sure you want to unlink this organization from its provider?')) { + $.ajax({ + type: "POST", + url: `@Url.Action("UnlinkOrganizationFromProvider", "Organizations")?id=${id}`, + dataType: 'json', + contentType: false, + processData: false, + success: function (response) { + alert("Successfully unlinked provider"); + window.location.href = `@Url.Action("Edit", "Organizations")?id=${id}`; + }, + error: function (response) { + alert("Error!"); + } + }); + } + return false; + } + /*** * Set Secrets Manager values based on current usage (for migrating from SM beta or reinstating an old subscription) */ diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 3293041a2..1683af2b6 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -40,7 +40,6 @@ public class OrganizationsController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; - private readonly IProviderRepository _providerRepository; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; private readonly IPaymentService _paymentService; @@ -51,7 +50,6 @@ public class OrganizationsController : Controller private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; @@ -64,7 +62,6 @@ public class OrganizationsController : Controller IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - IProviderRepository providerRepository, IOrganizationService organizationService, IUserService userService, IPaymentService paymentService, @@ -75,7 +72,6 @@ public class OrganizationsController : Controller IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand, ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand, IOrganizationApiKeyRepository organizationApiKeyRepository, - IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand, ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, @@ -87,7 +83,6 @@ public class OrganizationsController : Controller _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; - _providerRepository = providerRepository; _organizationService = organizationService; _userService = userService; _paymentService = paymentService; @@ -98,7 +93,6 @@ public class OrganizationsController : Controller _rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand; _createOrganizationApiKeyCommand = createOrganizationApiKeyCommand; _organizationApiKeyRepository = organizationApiKeyRepository; - _updateOrganizationLicenseCommand = updateOrganizationLicenseCommand; _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; _featureService = featureService; _globalSettings = globalSettings; @@ -245,6 +239,21 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); } + [HttpGet("{id}/risks-subscription-failure")] + public async Task RisksSubscriptionFailure(Guid id) + { + if (!await _currentContext.EditPaymentMethods(id)) + { + return new OrganizationRisksSubscriptionFailureResponseModel(id, false); + } + + var organization = await _organizationRepository.GetByIdAsync(id); + + var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization); + + return new OrganizationRisksSubscriptionFailureResponseModel(id, risksSubscriptionFailure); + } + [HttpPost("")] [SelfHosted(NotSelfHostedOnly = true)] public async Task Post([FromBody] OrganizationCreateRequestModel model) diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 4d734e7ca..136119848 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -1,10 +1,13 @@ using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.Models.Response; +using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Commands; using Bit.Core.Context; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -16,22 +19,33 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class ProviderOrganizationsController : Controller { - - private readonly IProviderOrganizationRepository _providerOrganizationRepository; - private readonly IProviderService _providerService; - private readonly IUserService _userService; private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; + private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; + private readonly IUserService _userService; public ProviderOrganizationsController( + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, IProviderOrganizationRepository providerOrganizationRepository, + IProviderRepository providerRepository, IProviderService providerService, - IUserService userService, - ICurrentContext currentContext) + IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, + IRemovePaymentMethodCommand removePaymentMethodCommand, + IUserService userService) { - _providerOrganizationRepository = providerOrganizationRepository; - _providerService = providerService; - _userService = userService; _currentContext = currentContext; + _organizationRepository = organizationRepository; + _providerOrganizationRepository = providerOrganizationRepository; + _providerRepository = providerRepository; + _providerService = providerService; + _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; + _removePaymentMethodCommand = removePaymentMethodCommand; + _userService = userService; } [HttpGet("")] @@ -87,7 +101,17 @@ public class ProviderOrganizationsController : Controller throw new NotFoundException(); } - var userId = _userService.GetProperUserId(User); - await _providerService.RemoveOrganizationAsync(providerId, id, userId.Value); + var provider = await _providerRepository.GetByIdAsync(providerId); + + var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id); + + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + + await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( + provider, + providerOrganization, + organization); + + await _removePaymentMethodCommand.RemovePaymentMethod(organization); } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs new file mode 100644 index 000000000..e91275da3 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs @@ -0,0 +1,17 @@ +using Bit.Core.Models.Api; + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class OrganizationRisksSubscriptionFailureResponseModel : ResponseModel +{ + public Guid OrganizationId { get; } + public bool RisksSubscriptionFailure { get; } + + public OrganizationRisksSubscriptionFailureResponseModel( + Guid organizationId, + bool risksSubscriptionFailure) : base("organizationRisksSubscriptionFailure") + { + OrganizationId = organizationId; + RisksSubscriptionFailure = risksSubscriptionFailure; + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index b82b2e1c2..7b5067f3f 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -26,6 +26,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures; using Bit.Core.Entities; +using Bit.Core.Billing.Extensions; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -169,6 +170,7 @@ public class Startup services.AddDefaultServices(globalSettings); services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); + services.AddBillingCommands(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs new file mode 100644 index 000000000..84013adc1 --- /dev/null +++ b/src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; + +namespace Bit.Core.AdminConsole.Providers.Interfaces; + +public interface IRemoveOrganizationFromProviderCommand +{ + Task RemoveOrganizationFromProvider( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization); +} diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index f71403e80..fdaef4c03 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -23,7 +23,6 @@ public interface IProviderService Task AddOrganizationsToReseller(Guid providerId, IEnumerable organizationIds); Task CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user); - Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId); Task LogProviderAccessToOrganizationAsync(Guid organizationId); Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId); Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail); diff --git a/src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs b/src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs new file mode 100644 index 000000000..62bf0d092 --- /dev/null +++ b/src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.Billing.Commands; + +public interface IRemovePaymentMethodCommand +{ + Task RemovePaymentMethod(Organization organization); +} diff --git a/src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs b/src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs new file mode 100644 index 000000000..c5dbb6d92 --- /dev/null +++ b/src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs @@ -0,0 +1,140 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Braintree; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Billing.Commands.Implementations; + +public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand +{ + private readonly IBraintreeGateway _braintreeGateway; + private readonly ILogger _logger; + private readonly IStripeAdapter _stripeAdapter; + + public RemovePaymentMethodCommand( + IBraintreeGateway braintreeGateway, + ILogger logger, + IStripeAdapter stripeAdapter) + { + _braintreeGateway = braintreeGateway; + _logger = logger; + _stripeAdapter = stripeAdapter; + } + + public async Task RemovePaymentMethod(Organization organization) + { + const string braintreeCustomerIdKey = "btCustomerId"; + + if (organization == null) + { + throw new ArgumentNullException(nameof(organization)); + } + + if (organization.Gateway is not GatewayType.Stripe || string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + throw ContactSupport(); + } + + var stripeCustomer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions + { + Expand = new List { "invoice_settings.default_payment_method", "sources" } + }); + + if (stripeCustomer == null) + { + _logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId); + + throw ContactSupport(); + } + + if (stripeCustomer.Metadata?.TryGetValue(braintreeCustomerIdKey, out var braintreeCustomerId) ?? false) + { + await RemoveBraintreePaymentMethodAsync(braintreeCustomerId); + } + else + { + await RemoveStripePaymentMethodsAsync(stripeCustomer); + } + } + + private async Task RemoveBraintreePaymentMethodAsync(string braintreeCustomerId) + { + var customer = await _braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + if (customer == null) + { + _logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId); + + throw ContactSupport(); + } + + if (customer.DefaultPaymentMethod != null) + { + var existingDefaultPaymentMethod = customer.DefaultPaymentMethod; + + var updateCustomerResult = await _braintreeGateway.Customer.UpdateAsync( + braintreeCustomerId, + new CustomerRequest { DefaultPaymentMethodToken = null }); + + if (!updateCustomerResult.IsSuccess()) + { + _logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}", + braintreeCustomerId, updateCustomerResult.Message); + + throw ContactSupport(); + } + + var deletePaymentMethodResult = await _braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token); + + if (!deletePaymentMethodResult.IsSuccess()) + { + await _braintreeGateway.Customer.UpdateAsync( + braintreeCustomerId, + new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token }); + + _logger.LogError( + "Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}", + braintreeCustomerId, deletePaymentMethodResult.Message); + + throw ContactSupport(); + } + } + else + { + _logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId); + } + } + + private async Task RemoveStripePaymentMethodsAsync(Stripe.Customer customer) + { + if (customer.Sources != null && customer.Sources.Any()) + { + foreach (var source in customer.Sources) + { + switch (source) + { + case Stripe.BankAccount: + await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); + break; + case Stripe.Card: + await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id); + break; + } + } + } + + var paymentMethods = _stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions + { + Customer = customer.Id + }); + + await foreach (var paymentMethod in paymentMethods) + { + await _stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions()); + } + } + + private static GatewayException ContactSupport() => new("Could not remove your payment method. Please contact support for assistance."); +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..37857cf3c --- /dev/null +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Commands.Implementations; + +namespace Bit.Core.Billing.Extensions; + +using Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddBillingCommands(this IServiceCollection services) + { + services.AddSingleton(); + } +} diff --git a/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs new file mode 100644 index 000000000..7b666cdd9 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs @@ -0,0 +1,27 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + +
+ Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information. +
+ To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> Payment Method. +
+ For more information, please refer to the following help article: Update billing information for organizations +
+ + Add payment method + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs new file mode 100644 index 000000000..56a857a6e --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs @@ -0,0 +1,7 @@ +{{#>BasicTextLayout}} + Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information. + + To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> Payment Method. + + Or click the following link: {{{link PaymentMethodUrl}}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs new file mode 100644 index 000000000..114aaa7c9 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Mail.Provider; + +public class ProviderUpdatePaymentMethodViewModel : BaseMailModel +{ + public string OrganizationId { get; set; } + public string OrganizationName { get; set; } + public string ProviderName { get; set; } + + public string PaymentMethodUrl => + $"{WebVaultUrl}/organizations/{OrganizationId}/billing/payment-method"; +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index c2d81d6ed..93c6fd6e3 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -60,6 +60,11 @@ public interface IMailService Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email); + Task SendProviderUpdatePaymentMethod( + Guid organizationId, + string organizationName, + string providerName, + IEnumerable emails); Task SendUpdatedTempPasswordEmailAsync(string email, string userName); Task SendFamiliesForEnterpriseOfferEmailAsync(string sponsorOrgName, string email, bool existingAccount, string token); Task BulkSendFamiliesForEnterpriseOfferEmailAsync(string SponsorOrgName, IEnumerable<(string Email, bool ExistingAccount, string Token)> invites); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index a66d227d3..70cc88c20 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -49,4 +49,5 @@ public interface IPaymentService Task ArchiveTaxRateAsync(TaxRate taxRate); Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount, DateTime? prorationDate = null); + Task RisksSubscriptionFailure(Organization organization); } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 60d14ffad..073d5cdac 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -23,6 +23,7 @@ public interface IStripeAdapter Task InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null); Task InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null); IEnumerable PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options); + IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 8805e3af5..90b273bed 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -754,6 +754,30 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendProviderUpdatePaymentMethod( + Guid organizationId, + string organizationName, + string providerName, + IEnumerable emails) + { + var message = CreateDefaultMessage("Update your billing information", emails); + + var model = new ProviderUpdatePaymentMethodViewModel + { + OrganizationId = organizationId.ToString(), + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName), + ProviderName = CoreHelpers.SanitizeForEmail(providerName), + SiteName = _globalSettings.SiteName, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash + }; + + await AddMessageContentAsync(message, "Provider.ProviderUpdatePaymentMethod", model); + + message.Category = "ProviderUpdatePaymentMethod"; + + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendUpdatedTempPasswordEmailAsync(string email, string userName) { var message = CreateDefaultMessage("Master Password Has Been Changed", email); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 747510d05..ef8d13aea 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -138,6 +138,9 @@ public class StripeAdapter : IStripeAdapter return _paymentMethodService.ListAutoPaging(options); } + public IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options) + => _paymentMethodService.ListAutoPagingAsync(options); + public Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null) { return _paymentMethodService.AttachAsync(id, options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 8eae90ea2..1aeda8807 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1614,6 +1614,23 @@ public class StripePaymentService : IPaymentService return await FinalizeSubscriptionChangeAsync(org, new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), prorationDate); } + public async Task RisksSubscriptionFailure(Organization organization) + { + var subscriptionInfo = await GetSubscriptionAsync(organization); + + if (subscriptionInfo.Subscription is not { Status: "active" or "trialing" or "past_due" } || + subscriptionInfo.UpcomingInvoice == null) + { + return false; + } + + var customer = await GetCustomerAsync(organization.GatewayCustomerId); + + var paymentSource = await GetBillingPaymentSourceAsync(customer); + + return paymentSource == null; + } + private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 92e548e0d..81419b186 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -197,6 +197,9 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendProviderUpdatePaymentMethod(Guid organizationId, string organizationName, string providerName, + IEnumerable emails) => Task.FromResult(0); + public Task SendUpdatedTempPasswordEmailAsync(string email, string userName) { return Task.FromResult(0); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 467fb8f8a..f4c771ade 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -7,14 +7,21 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Dapper; using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; namespace Bit.Infrastructure.Dapper.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - public OrganizationRepository(GlobalSettings globalSettings) + private readonly ILogger _logger; + + public OrganizationRepository( + GlobalSettings globalSettings, + ILogger logger) : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) - { } + { + _logger = logger; + } public OrganizationRepository(string connectionString, string readOnlyConnectionString) : base(connectionString, readOnlyConnectionString) @@ -153,6 +160,8 @@ public class OrganizationRepository : Repository, IOrganizat public async Task> GetOwnerEmailAddressesById(Guid organizationId) { + _logger.LogInformation("AC-1758: Executing GetOwnerEmailAddressesById (Dapper)"); + await using var connection = new SqlConnection(ConnectionString); return await connection.QueryAsync( diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 6ad8cfbb4..acc36c944 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -5,15 +5,23 @@ using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Organization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; namespace Bit.Infrastructure.EntityFramework.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - public OrganizationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) - : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Organizations) - { } + private readonly ILogger _logger; + + public OrganizationRepository( + IServiceScopeFactory serviceScopeFactory, + IMapper mapper, + ILogger logger) + : base(serviceScopeFactory, mapper, context => context.Organizations) + { + _logger = logger; + } public async Task GetByIdentifierAsync(string identifier) { @@ -240,6 +248,8 @@ public class OrganizationRepository : Repository> GetOwnerEmailAddressesById(Guid organizationId) { + _logger.LogInformation("AC-1758: Executing GetOwnerEmailAddressesById (Entity Framework)"); + using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index fd24c47af..0e4ae4887 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -37,7 +37,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; - private readonly IProviderRepository _providerRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; private readonly IUserService _userService; @@ -46,7 +45,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; - private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly IFeatureService _featureService; private readonly ILicensingService _licensingService; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; @@ -64,7 +62,6 @@ public class OrganizationsControllerTests : IDisposable _organizationUserRepository = Substitute.For(); _paymentService = Substitute.For(); _policyRepository = Substitute.For(); - _providerRepository = Substitute.For(); _ssoConfigRepository = Substitute.For(); _ssoConfigService = Substitute.For(); _getOrganizationApiKeyQuery = Substitute.For(); @@ -73,19 +70,33 @@ public class OrganizationsControllerTests : IDisposable _userService = Substitute.For(); _cloudGetOrganizationLicenseQuery = Substitute.For(); _createOrganizationApiKeyCommand = Substitute.For(); - _updateOrganizationLicenseCommand = Substitute.For(); _featureService = Substitute.For(); _licensingService = Substitute.For(); _updateSecretsManagerSubscriptionCommand = Substitute.For(); _upgradeOrganizationPlanCommand = Substitute.For(); _addSecretsManagerSubscriptionCommand = Substitute.For(); - _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, - _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, - _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, - _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService, - _updateSecretsManagerSubscriptionCommand, _upgradeOrganizationPlanCommand, _addSecretsManagerSubscriptionCommand); + _sut = new OrganizationsController( + _organizationRepository, + _organizationUserRepository, + _policyRepository, + _organizationService, + _userService, + _paymentService, + _currentContext, + _ssoConfigRepository, + _ssoConfigService, + _getOrganizationApiKeyQuery, + _rotateOrganizationApiKeyCommand, + _createOrganizationApiKeyCommand, + _organizationApiKeyRepository, + _cloudGetOrganizationLicenseQuery, + _featureService, + _globalSettings, + _licensingService, + _updateSecretsManagerSubscriptionCommand, + _upgradeOrganizationPlanCommand, + _addSecretsManagerSubscriptionCommand); } public void Dispose() diff --git a/test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs new file mode 100644 index 000000000..5de14f006 --- /dev/null +++ b/test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs @@ -0,0 +1,367 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using BT = Braintree; +using S = Stripe; + +namespace Bit.Core.Test.Billing.Commands; + +[SutProviderCustomize] +public class RemovePaymentMethodCommandTests +{ + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NullOrganization_ArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.RemovePaymentMethod(null)); + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NonStripeGateway_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.BitPay; + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NoGatewayCustomerId_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + organization.GatewayCustomerId = null; + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NoStripeCustomer_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .ReturnsNull(); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_NoCustomer_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (braintreeGateway, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + customerGateway.FindAsync(braintreeCustomerId).ReturnsNull(); + + braintreeGateway.Customer.Returns(customerGateway); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.DidNotReceiveWithAnyArgs() + .UpdateAsync(Arg.Any(), Arg.Any()); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_NoPaymentMethod_NoOp( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + braintreeCustomer.PaymentMethods.Returns(Array.Empty()); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + await sutProvider.Sut.RemovePaymentMethod(organization); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any(), Arg.Any()); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_CustomerUpdateFails_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + const string braintreePaymentMethodToken = "TOKEN"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + var paymentMethod = Substitute.For(); + paymentMethod.Token.Returns(braintreePaymentMethodToken); + paymentMethod.IsDefault.Returns(true); + + braintreeCustomer.PaymentMethods.Returns(new[] + { + paymentMethod + }); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + var updateBraintreeCustomerResult = Substitute.For>(); + updateBraintreeCustomerResult.IsSuccess().Returns(false); + + customerGateway.UpdateAsync( + braintreeCustomerId, + Arg.Is(request => request.DefaultPaymentMethodToken == null)) + .Returns(updateBraintreeCustomerResult); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == null)); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(paymentMethod.Token); + + await customerGateway.DidNotReceive().UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == paymentMethod.Token)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_PaymentMethodDeleteFails_RollBack_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + const string braintreePaymentMethodToken = "TOKEN"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + var paymentMethod = Substitute.For(); + paymentMethod.Token.Returns(braintreePaymentMethodToken); + paymentMethod.IsDefault.Returns(true); + + braintreeCustomer.PaymentMethods.Returns(new[] + { + paymentMethod + }); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + var updateBraintreeCustomerResult = Substitute.For>(); + updateBraintreeCustomerResult.IsSuccess().Returns(true); + + customerGateway.UpdateAsync(braintreeCustomerId, Arg.Any()) + .Returns(updateBraintreeCustomerResult); + + var deleteBraintreePaymentMethodResult = Substitute.For>(); + deleteBraintreePaymentMethodResult.IsSuccess().Returns(false); + + paymentMethodGateway.DeleteAsync(paymentMethod.Token).Returns(deleteBraintreePaymentMethodResult); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == null)); + + await paymentMethodGateway.Received(1).DeleteAsync(paymentMethod.Token); + + await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == paymentMethod.Token)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Stripe_Legacy_RemovesSources( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string bankAccountId = "bank_account_id"; + const string cardId = "card_id"; + + var sources = new List + { + new S.BankAccount { Id = bankAccountId }, new S.Card { Id = cardId } + }; + + var stripeCustomer = new S.Customer { Sources = new S.StripeList { Data = sources } }; + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + stripeAdapter + .PaymentMethodListAutoPagingAsync(Arg.Any()) + .Returns(GetPaymentMethodsAsync(new List())); + + await sutProvider.Sut.RemovePaymentMethod(organization); + + await stripeAdapter.Received(1).BankAccountDeleteAsync(stripeCustomer.Id, bankAccountId); + + await stripeAdapter.Received(1).CardDeleteAsync(stripeCustomer.Id, cardId); + + await stripeAdapter.DidNotReceiveWithAnyArgs() + .PaymentMethodDetachAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Stripe_DetachesPaymentMethods( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + const string bankAccountId = "bank_account_id"; + const string cardId = "card_id"; + + var sources = new List(); + + var stripeCustomer = new S.Customer { Sources = new S.StripeList { Data = sources } }; + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + stripeAdapter + .PaymentMethodListAutoPagingAsync(Arg.Any()) + .Returns(GetPaymentMethodsAsync(new List + { + new () + { + Id = bankAccountId + }, + new () + { + Id = cardId + } + })); + + await sutProvider.Sut.RemovePaymentMethod(organization); + + await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any(), Arg.Any()); + + await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any(), Arg.Any()); + + await stripeAdapter.Received(1) + .PaymentMethodDetachAsync(bankAccountId, Arg.Any()); + + await stripeAdapter.Received(1) + .PaymentMethodDetachAsync(cardId, Arg.Any()); + } + + private static async IAsyncEnumerable GetPaymentMethodsAsync( + IEnumerable paymentMethods) + { + foreach (var paymentMethod in paymentMethods) + { + yield return paymentMethod; + } + + await Task.CompletedTask; + } + + private static (BT.IBraintreeGateway, BT.ICustomerGateway, BT.IPaymentMethodGateway) Setup( + BT.IBraintreeGateway braintreeGateway) + { + var customerGateway = Substitute.For(); + var paymentMethodGateway = Substitute.For(); + + braintreeGateway.Customer.Returns(customerGateway); + braintreeGateway.PaymentMethod.Returns(paymentMethodGateway); + + return (braintreeGateway, customerGateway, paymentMethodGateway); + } + + private static async Task ThrowsContactSupportAsync(Func function) + { + const string message = "Could not remove your payment method. Please contact support for assistance."; + + var exception = await Assert.ThrowsAsync(function); + + Assert.Equal(message, exception.Message); + } +}