1
0
mirror of https://github.com/bitwarden/server synced 2025-12-11 05:43:35 +00:00

[PM-26692] Count unverified setup intent as payment method during organization subscription creation (#6433)

* Updated check that determines whether org has payment method to include bank account when determining how to set trial_settings

* Run dotnet format
This commit is contained in:
Alex Morask
2025-10-09 13:20:28 -05:00
committed by GitHub
parent 712926996e
commit 34f5ffd981
6 changed files with 339 additions and 54 deletions

View File

@@ -2,11 +2,11 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Services; using Bit.Core.Services;
@@ -30,8 +30,8 @@ public interface IGetOrganizationWarningsQuery
public class GetOrganizationWarningsQuery( public class GetOrganizationWarningsQuery(
ICurrentContext currentContext, ICurrentContext currentContext,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IProviderRepository providerRepository, IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetOrganizationWarningsQuery ISubscriberService subscriberService) : IGetOrganizationWarningsQuery
{ {
@@ -81,15 +81,7 @@ public class GetOrganizationWarningsQuery(
return null; return null;
} }
var customer = subscription.Customer; var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization);
var hasPaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
hasUnverifiedBankAccount ||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
if (hasPaymentMethod) if (hasPaymentMethod)
{ {
@@ -287,22 +279,4 @@ public class GetOrganizationWarningsQuery(
_ => null _ => null
}; };
} }
private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization)
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return false;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
return setupIntent.IsUnverifiedBankAccount();
}
} }

View File

@@ -6,6 +6,7 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
@@ -27,6 +28,7 @@ namespace Bit.Core.Billing.Organizations.Services;
public class OrganizationBillingService( public class OrganizationBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IHasPaymentMethodQuery hasPaymentMethodQuery,
ILogger<OrganizationBillingService> logger, ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPricingClient pricingClient, IPricingClient pricingClient,
@@ -43,7 +45,7 @@ public class OrganizationBillingService(
? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
: await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup);
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup);
if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active)
{ {
@@ -120,8 +122,7 @@ public class OrganizationBillingService(
orgOccupiedSeats.Total); orgOccupiedSeats.Total);
} }
public async Task public async Task UpdatePaymentMethod(
UpdatePaymentMethod(
Organization organization, Organization organization,
TokenizedPaymentSource tokenizedPaymentSource, TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation) TaxInformation taxInformation)
@@ -397,7 +398,7 @@ public class OrganizationBillingService(
} }
private async Task<Subscription> CreateSubscriptionAsync( private async Task<Subscription> CreateSubscriptionAsync(
Guid organizationId, Organization organization,
Customer customer, Customer customer,
SubscriptionSetup subscriptionSetup) SubscriptionSetup subscriptionSetup)
{ {
@@ -465,7 +466,7 @@ public class OrganizationBillingService(
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
["organizationId"] = organizationId.ToString(), ["organizationId"] = organization.Id.ToString(),
["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) && ["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) &&
subscriptionSetup.InitiationPath.Contains("trial from marketing website") subscriptionSetup.InitiationPath.Contains("trial from marketing website")
? "marketing-initiated" ? "marketing-initiated"
@@ -475,9 +476,10 @@ public class OrganizationBillingService(
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
}; };
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
// Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method // Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method
if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) && if (!hasPaymentMethod)
!customer.Metadata.ContainsKey(BraintreeCustomerIdKey))
{ {
subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions
{ {

View File

@@ -0,0 +1,58 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Payment.Queries;
using static StripeConstants;
public interface IHasPaymentMethodQuery
{
Task<bool> Run(ISubscriber subscriber);
}
public class HasPaymentMethodQuery(
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IHasPaymentMethodQuery
{
public async Task<bool> Run(ISubscriber subscriber)
{
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(subscriber);
var customer = await subscriberService.GetCustomer(subscriber);
if (customer == null)
{
return hasUnverifiedBankAccount;
}
return
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
hasUnverifiedBankAccount ||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
}
private async Task<bool> HasUnverifiedBankAccountAsync(
ISubscriber subscriber)
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return false;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
return setupIntent.IsUnverifiedBankAccount();
}
}

View File

@@ -19,5 +19,6 @@ public static class Registrations
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>(); services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();
services.AddTransient<IGetCreditQuery, GetCreditQuery>(); services.AddTransient<IGetCreditQuery, GetCreditQuery>();
services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>(); services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>();
services.AddTransient<IHasPaymentMethodQuery, HasPaymentMethodQuery>();
} }
} }

View File

@@ -2,10 +2,10 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Services; using Bit.Core.Services;
@@ -75,7 +75,7 @@ public class GetOrganizationWarningsQueryTests
}); });
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true); sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null); sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(false);
var response = await sutProvider.Sut.Run(organization); var response = await sutProvider.Sut.Run(organization);
@@ -86,12 +86,11 @@ public class GetOrganizationWarningsQueryTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning( public async Task Run_Has_FreeTrialWarning_WithPaymentMethod_NoWarning(
Organization organization, Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider) SutProvider<GetOrganizationWarningsQuery> sutProvider)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
const string setupIntentId = "setup_intent_id";
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options => .GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
@@ -113,20 +112,7 @@ public class GetOrganizationWarningsQueryTests
}); });
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true); sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId); sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(
options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var response = await sutProvider.Sut.Run(organization); var response = await sutProvider.Sut.Run(organization);

View File

@@ -0,0 +1,264 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Queries;
using static StripeConstants;
public class HasPaymentMethodQueryTests
{
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly HasPaymentMethodQuery _query;
public HasPaymentMethodQueryTests()
{
_query = new HasPaymentMethodQuery(
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
[Fact]
public async Task Run_NoCustomer_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoPaymentMethod_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_HasDefaultPaymentMethodId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
},
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasDefaultSourceId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
DefaultSourceId = "card_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasBraintreeCustomerId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "braintree_customer_id"
}
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoSetupIntentId_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_SetupIntentNotBankAccount_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
PaymentMethod = new PaymentMethod
{
Type = "card"
},
Status = "succeeded"
});
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
}