mirror of
https://github.com/bitwarden/server.git
synced 2025-07-03 09:02:48 -05: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:
@ -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);
|
||||
|
Reference in New Issue
Block a user