Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[AC-2471] Prevent calls to Stripe when unlinking client org has no Stripe objects #3999

Merged
merged 10 commits into from
May 9, 2024
Merged
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);
}
}
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
Expand Up @@ -349,7 +349,10 @@
providerOrganization,
organization);

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

Check warning on line 355 in src/Admin/AdminConsole/Controllers/OrganizationsController.cs

View check run for this annotation

Codecov / codecov/patch

src/Admin/AdminConsole/Controllers/OrganizationsController.cs#L353-L355

Added lines #L353 - L355 were not covered by tests

return Json(null);
}
Expand Down
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 @@
}


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

Check warning on line 76 in src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs

View check run for this annotation

Codecov / codecov/patch

src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs#L74-L76

Added lines #L74 - L76 were not covered by tests

return Json(null);
}
Expand Down
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 @@
providerOrganization,
organization);

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

Check warning on line 119 in src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs

View check run for this annotation

Codecov / codecov/patch

src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs#L117-L119

Added lines #L117 - L119 were not covered by tests
}
}
4 changes: 4 additions & 0 deletions src/Core/Billing/Extensions/BillingExtensions.cs
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;
}