Skip to content

Commit

Permalink
[AC-2471] Prevent calls to Stripe when unlinking client org has no St…
Browse files Browse the repository at this point in the history
…ripe objects (#3999)

* Prevent calls to Stripe when unlinking client org has no Stripe objects

* Thomas' feedback

* Check for stripe when org unlinked from org page

---------

Co-authored-by: Conner Turnbull <[email protected]>
  • Loading branch information
amorask-bitwarden and cturnbull-bitwarden committed May 9, 2024
1 parent fa7b00a commit ac4ccaf
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -76,6 +77,35 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv

organization.BillingEmail = organizationOwnerEmails.MinBy(email => email);

await ResetOrganizationBillingAsync(organization, provider, organizationOwnerEmails);

await _organizationRepository.ReplaceAsync(organization);

await _providerOrganizationRepository.DeleteAsync(providerOrganization);

await _eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
}

/// <summary>
/// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled
/// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because
/// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly,
/// we email the organization owners letting them know they need to add a new payment method.
/// </summary>
private async Task ResetOrganizationBillingAsync(
Organization organization,
Provider provider,
IEnumerable<string> organizationOwnerEmails)
{
if (!organization.IsStripeEnabled())
{
return;
}

var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);

var customerUpdateOptions = new CustomerUpdateOptions
{
Coupon = string.Empty,
Expand All @@ -84,11 +114,10 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv

await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);

var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);

if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
{
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;

var subscriptionCreateOptions = new SubscriptionCreateOptions
{
Customer = organization.GatewayCustomerId,
Expand All @@ -103,8 +132,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }]
};

var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);

organization.GatewaySubscriptionId = subscription.Id;

await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
-(organization.Seats ?? 0));
}
Expand All @@ -115,21 +147,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
CollectionMethod = "send_invoice",
DaysUntilDue = 30
};

await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
}

await _organizationRepository.ReplaceAsync(organization);

await _mailService.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
organizationOwnerEmails);

await _providerOrganizationRepository.DeleteAsync(providerOrganization);

await _eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using NSubstitute;
using Stripe;
using Xunit;
using IMailService = Bit.Core.Services.IMailService;

namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;

Expand Down Expand Up @@ -83,6 +84,55 @@ public class RemoveOrganizationFromProviderCommandTests
Assert.Equal("Organization must have at least one confirmed owner.", exception.Message);
}

[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
organization.GatewayCustomerId = null;
organization.GatewaySubscriptionId = null;

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> { "[email protected]", "[email protected]" };

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 == "[email protected]"));

var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();

await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());

await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());

await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod(
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<IEnumerable<string>>());

await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);

await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
}

[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
Provider provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,10 @@ public async Task<IActionResult> UnlinkOrganizationFromProviderAsync(Guid id)
providerOrganization,
organization);

await _removePaymentMethodCommand.RemovePaymentMethod(organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}

return Json(null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
Expand Down Expand Up @@ -69,7 +70,10 @@ public async Task<IActionResult> DeleteAsync(Guid providerId, Guid id)
}


await _removePaymentMethodCommand.RemovePaymentMethod(organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}

return Json(null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -112,6 +113,9 @@ public async Task Delete(Guid providerId, Guid id)
providerOrganization,
organization);

await _removePaymentMethodCommand.RemovePaymentMethod(organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
}
}
4 changes: 4 additions & 0 deletions src/Core/Billing/Extensions/BillingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public static bool IsValidClient(this Organization organization)
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
};

public static bool IsStripeEnabled(this Organization organization)
=> !string.IsNullOrEmpty(organization.GatewayCustomerId) &&
!string.IsNullOrEmpty(organization.GatewaySubscriptionId);

public static bool SupportsConsolidatedBilling(this PlanType planType)
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
}

0 comments on commit ac4ccaf

Please sign in to comment.