mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
[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 <cturnbull@bitwarden.com>
This commit is contained in:
parent
505508a416
commit
95139def0f
@ -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<RemoveOrganizationFromProviderCommand> _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<RemoveOrganizationFromProviderCommand> 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<Guid>(),
|
||||
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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -12,5 +12,6 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<IProviderService, ProviderService>();
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
}
|
||||
}
|
||||
|
@ -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<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
includeProvider: false)
|
||||
.Returns(false);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "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<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(
|
||||
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||
options => options.Coupon == string.Empty && options.Email == "a@gmail.com"));
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(
|
||||
organization.GatewaySubscriptionId, Arg.Is<SubscriptionUpdateOptions>(
|
||||
options => options.CollectionMethod == "send_invoice" && options.DaysUntilDue == 30));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@gmail.com") && emails.Contains("b@gmail.com")));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
}
|
@ -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<ProviderService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganization.Id)
|
||||
.ReturnsNull();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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<ProviderService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganization.Id)
|
||||
.Returns(providerOrganization);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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<ProviderService> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganization.Id)
|
||||
.Returns(providerOrganization);
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(default, default, default)
|
||||
.ReturnsForAnyArgs(false);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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<ProviderService> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
providerOrganizationRepository.GetByIdAsync(providerOrganization.Id).Returns(providerOrganization);
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(default, default, default)
|
||||
.ReturnsForAnyArgs(true);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id);
|
||||
await providerOrganizationRepository.Received().DeleteAsync(providerOrganization);
|
||||
await sutProvider.GetDependency<IEventService>().Received()
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
|
@ -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<IActionResult> 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<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
|
67
src/Admin/Controllers/ProviderOrganizationsController.cs
Normal file
67
src/Admin/Controllers/ProviderOrganizationsController.cs
Normal file
@ -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<IActionResult> 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);
|
||||
}
|
||||
}
|
@ -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<IAccessControlService, AccessControlService>();
|
||||
services.AddBillingCommands();
|
||||
|
||||
#if OSS
|
||||
services.AddOosServices();
|
||||
|
@ -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 @@
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
<div class="ml-auto d-flex">
|
||||
@if (canInitiateTrial)
|
||||
@if (canInitiateTrial && Model.Provider is null)
|
||||
{
|
||||
<button class="btn btn-secondary mr-2" type="button" id="teams-trial">
|
||||
Teams Trial
|
||||
@ -90,6 +91,15 @@
|
||||
Enterprise Trial
|
||||
</button>
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button
|
||||
class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');"
|
||||
>
|
||||
Unlink provider
|
||||
</button>
|
||||
}
|
||||
@if (canDelete)
|
||||
{
|
||||
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||
|
@ -1,7 +1,15 @@
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Bit.Admin.Enums
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@model ProviderViewModel
|
||||
|
||||
@{
|
||||
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
|
||||
}
|
||||
|
||||
@await Html.PartialAsync("_ProviderScripts")
|
||||
@await Html.PartialAsync("_ProviderOrganizationScripts")
|
||||
|
||||
<h2>Provider Organizations</h2>
|
||||
<div class="row">
|
||||
@ -32,26 +40,28 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var org in Model.ProviderOrganizations)
|
||||
@foreach (var providerOrganization in Model.ProviderOrganizations)
|
||||
{
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@org.OrganizationId">@org.OrganizationName</a>
|
||||
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@providerOrganization.OrganizationId">@providerOrganization.OrganizationName</a>
|
||||
</td>
|
||||
<td>
|
||||
@org.Status
|
||||
@providerOrganization.Status
|
||||
</td>
|
||||
<td>
|
||||
<div class="float-right">
|
||||
@if (org.Status == OrganizationStatusType.Pending)
|
||||
@if (canUnlinkFromProvider)
|
||||
{
|
||||
<a href="#" class="float-right" onclick="return resendOwnerInvite('@org.OrganizationId');">
|
||||
<i class="fa fa-envelope-o fa-lg" title="Resend Setup Invite"></i>
|
||||
<a href="#" class="text-danger float-right" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
|
||||
Unlink provider
|
||||
</a>
|
||||
}
|
||||
else
|
||||
@if (providerOrganization.Status == OrganizationStatusType.Pending)
|
||||
{
|
||||
<i class="fa fa-envelope-o fa-lg text-secondary"></i>
|
||||
<a href="#" class="float-right mr-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
|
||||
Resend invitation
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
function unlinkProvider(providerId, id) {
|
||||
if (confirm('Are you sure you want to unlink this organization from its provider?')) {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: `@Url.Action("Delete", "ProviderOrganizations")?providerId=${providerId}&id=${id}`,
|
||||
dataType: 'json',
|
||||
contentType: false,
|
||||
processData: false,
|
||||
success: function (response) {
|
||||
alert("Successfully unlinked provider");
|
||||
window.location.href = `@Url.Action("Edit", "Providers")?id=${providerId}`;
|
||||
},
|
||||
error: function (response) {
|
||||
alert("Error!");
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
@ -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)
|
||||
*/
|
||||
|
@ -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<OrganizationRisksSubscriptionFailureResponseModel> 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<OrganizationResponseModel> Post([FromBody] OrganizationCreateRequestModel model)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
@ -23,7 +23,6 @@ public interface IProviderService
|
||||
Task AddOrganizationsToReseller(Guid providerId, IEnumerable<Guid> organizationIds);
|
||||
Task<ProviderOrganization> 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);
|
||||
|
8
src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs
Normal file
8
src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.Billing.Commands;
|
||||
|
||||
public interface IRemovePaymentMethodCommand
|
||||
{
|
||||
Task RemovePaymentMethod(Organization organization);
|
||||
}
|
@ -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<RemovePaymentMethodCommand> _logger;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
|
||||
public RemovePaymentMethodCommand(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
ILogger<RemovePaymentMethodCommand> 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<string> { "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.");
|
||||
}
|
14
src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Normal file
14
src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Normal file
@ -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<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> <a target="_blank" clicktracking=off href="{{PaymentMethodUrl}}" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #175DDC; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; text-decoration: underline;">Payment Method</a>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
For more information, please refer to the following help article: <a target="_blank" clicktracking=off href="https://bitwarden.com/help/update-billing-info/#update-billing-information-for-organizations" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #175DDC; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; text-decoration: underline;">Update billing information for organizations</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||
<a href="{{{PaymentMethodUrl}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Add payment method
|
||||
</a>
|
||||
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
@ -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}}
|
@ -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";
|
||||
}
|
@ -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<string> 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);
|
||||
|
@ -49,4 +49,5 @@ public interface IPaymentService
|
||||
Task ArchiveTaxRateAsync(TaxRate taxRate);
|
||||
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
|
||||
int additionalServiceAccount, DateTime? prorationDate = null);
|
||||
Task<bool> RisksSubscriptionFailure(Organization organization);
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ public interface IStripeAdapter
|
||||
Task<Stripe.Invoice> InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null);
|
||||
Task<Stripe.Invoice> InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null);
|
||||
IEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options);
|
||||
IAsyncEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options);
|
||||
Task<Stripe.PaymentMethod> PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null);
|
||||
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
|
||||
Task<Stripe.TaxRate> TaxRateCreateAsync(Stripe.TaxRateCreateOptions options);
|
||||
|
@ -754,6 +754,30 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendProviderUpdatePaymentMethod(
|
||||
Guid organizationId,
|
||||
string organizationName,
|
||||
string providerName,
|
||||
IEnumerable<string> 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);
|
||||
|
@ -138,6 +138,9 @@ public class StripeAdapter : IStripeAdapter
|
||||
return _paymentMethodService.ListAutoPaging(options);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options)
|
||||
=> _paymentMethodService.ListAutoPagingAsync(options);
|
||||
|
||||
public Task<Stripe.PaymentMethod> PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null)
|
||||
{
|
||||
return _paymentMethodService.AttachAsync(id, options);
|
||||
|
@ -1614,6 +1614,23 @@ public class StripePaymentService : IPaymentService
|
||||
return await FinalizeSubscriptionChangeAsync(org, new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), prorationDate);
|
||||
}
|
||||
|
||||
public async Task<bool> 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(
|
||||
|
@ -197,6 +197,9 @@ public class NoopMailService : IMailService
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendProviderUpdatePaymentMethod(Guid organizationId, string organizationName, string providerName,
|
||||
IEnumerable<string> emails) => Task.FromResult(0);
|
||||
|
||||
public Task SendUpdatedTempPasswordEmailAsync(string email, string userName)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
|
@ -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<Organization, Guid>, IOrganizationRepository
|
||||
{
|
||||
public OrganizationRepository(GlobalSettings globalSettings)
|
||||
private readonly ILogger<OrganizationRepository> _logger;
|
||||
|
||||
public OrganizationRepository(
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<OrganizationRepository> 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<Organization, Guid>, IOrganizat
|
||||
|
||||
public async Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId)
|
||||
{
|
||||
_logger.LogInformation("AC-1758: Executing GetOwnerEmailAddressesById (Dapper)");
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
|
||||
return await connection.QueryAsync<string>(
|
||||
|
@ -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<Core.AdminConsole.Entities.Organization, Organization, Guid>, IOrganizationRepository
|
||||
{
|
||||
public OrganizationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Organizations)
|
||||
{ }
|
||||
private readonly ILogger<OrganizationRepository> _logger;
|
||||
|
||||
public OrganizationRepository(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMapper mapper,
|
||||
ILogger<OrganizationRepository> logger)
|
||||
: base(serviceScopeFactory, mapper, context => context.Organizations)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Core.AdminConsole.Entities.Organization> GetByIdentifierAsync(string identifier)
|
||||
{
|
||||
@ -240,6 +248,8 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
||||
|
||||
public async Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId)
|
||||
{
|
||||
_logger.LogInformation("AC-1758: Executing GetOwnerEmailAddressesById (Entity Framework)");
|
||||
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
@ -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<IOrganizationUserRepository>();
|
||||
_paymentService = Substitute.For<IPaymentService>();
|
||||
_policyRepository = Substitute.For<IPolicyRepository>();
|
||||
_providerRepository = Substitute.For<IProviderRepository>();
|
||||
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
|
||||
_ssoConfigService = Substitute.For<ISsoConfigService>();
|
||||
_getOrganizationApiKeyQuery = Substitute.For<IGetOrganizationApiKeyQuery>();
|
||||
@ -73,19 +70,33 @@ public class OrganizationsControllerTests : IDisposable
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_cloudGetOrganizationLicenseQuery = Substitute.For<ICloudGetOrganizationLicenseQuery>();
|
||||
_createOrganizationApiKeyCommand = Substitute.For<ICreateOrganizationApiKeyCommand>();
|
||||
_updateOrganizationLicenseCommand = Substitute.For<IUpdateOrganizationLicenseCommand>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_licensingService = Substitute.For<ILicensingService>();
|
||||
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
|
||||
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
|
||||
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
|
||||
|
||||
_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()
|
||||
|
@ -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<RemovePaymentMethodCommand> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.RemovePaymentMethod(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NonStripeGateway_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.BitPay;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NoGatewayCustomerId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> 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<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (braintreeGateway, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
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<string>(), Arg.Any<BT.CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoPaymentMethod_NoOp(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(Array.Empty<BT.PaymentMethod>());
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any<string>(), Arg.Any<BT.CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_CustomerUpdateFails_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<BT.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(new[]
|
||||
{
|
||||
paymentMethod
|
||||
});
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<BT.Result<BT.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(false);
|
||||
|
||||
customerGateway.UpdateAsync(
|
||||
braintreeCustomerId,
|
||||
Arg.Is<BT.CustomerRequest>(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<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.DidNotReceive().UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_PaymentMethodDeleteFails_RollBack_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<BT.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(new[]
|
||||
{
|
||||
paymentMethod
|
||||
});
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<BT.Result<BT.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(true);
|
||||
|
||||
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Any<BT.CustomerRequest>())
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
var deleteBraintreePaymentMethodResult = Substitute.For<BT.Result<BT.PaymentMethod>>();
|
||||
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<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.Received(1).DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_Legacy_RemovesSources(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<S.IPaymentSource>
|
||||
{
|
||||
new S.BankAccount { Id = bankAccountId }, new S.Card { Id = cardId }
|
||||
};
|
||||
|
||||
var stripeCustomer = new S.Customer { Sources = new S.StripeList<S.IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<S.PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<S.PaymentMethod>()));
|
||||
|
||||
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<string>(), Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_DetachesPaymentMethods(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<S.IPaymentSource>();
|
||||
|
||||
var stripeCustomer = new S.Customer { Sources = new S.StripeList<S.IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<S.PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<S.PaymentMethod>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = bankAccountId
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Id = cardId
|
||||
}
|
||||
}));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(bankAccountId, Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(cardId, Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<S.PaymentMethod> GetPaymentMethodsAsync(
|
||||
IEnumerable<S.PaymentMethod> 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<BT.ICustomerGateway>();
|
||||
var paymentMethodGateway = Substitute.For<BT.IPaymentMethodGateway>();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
braintreeGateway.PaymentMethod.Returns(paymentMethodGateway);
|
||||
|
||||
return (braintreeGateway, customerGateway, paymentMethodGateway);
|
||||
}
|
||||
|
||||
private static async Task ThrowsContactSupportAsync(Func<Task> function)
|
||||
{
|
||||
const string message = "Could not remove your payment method. Please contact support for assistance.";
|
||||
|
||||
var exception = await Assert.ThrowsAsync<GatewayException>(function);
|
||||
|
||||
Assert.Equal(message, exception.Message);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user