1
0
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:
Alex Morask
2024-01-12 10:38:47 -05:00
committed by GitHub
parent 505508a416
commit 95139def0f
35 changed files with 1168 additions and 119 deletions

View File

@ -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);
}

View File

@ -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);

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.Billing.Commands;
public interface IRemovePaymentMethodCommand
{
Task RemovePaymentMethod(Organization organization);
}

View File

@ -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.");
}

View 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>();
}
}

View File

@ -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}}

View File

@ -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}}

View File

@ -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";
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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(

View File

@ -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);