1
0
mirror of https://github.com/bitwarden/server synced 2026-02-19 10:53:34 +00:00

[PM-31040] Replace ISetupIntentCache with customer-based approach (#6954)

* docs(billing): add design document for replacing SetupIntent cache

* docs(billing): add implementation plan for replacing SetupIntent cache

* feat(db): add gateway lookup stored procedures for Organization, Provider, and User

* feat(db): add gateway lookup indexes to Organization, Provider, and User table definitions

* chore(db): add SQL Server migration for gateway lookup indexes and stored procedures

* feat(repos): add gateway lookup methods to IOrganizationRepository and Dapper implementation

* feat(repos): add gateway lookup methods to IProviderRepository and Dapper implementation

* feat(repos): add gateway lookup methods to IUserRepository and Dapper implementation

* feat(repos): add EF OrganizationRepository gateway lookup methods and index configuration

* feat(repos): add EF ProviderRepository gateway lookup methods and index configuration

* feat(repos): add EF UserRepository gateway lookup methods and index configuration

* chore(db): add EF migrations for gateway lookup indexes

* refactor(billing): update SetupIntentSucceededHandler to use repository instead of cache

* refactor(billing): simplify StripeEventService by expanding customer on SetupIntent

* refactor(billing): query Stripe for SetupIntents by customer ID in GetPaymentMethodQuery

* refactor(billing): query Stripe for SetupIntents by customer ID in HasPaymentMethodQuery

* refactor(billing): update OrganizationBillingService to set customer on SetupIntent

* refactor(billing): update ProviderBillingService to set customer on SetupIntent and query by customer

* refactor(billing): update UpdatePaymentMethodCommand to set customer on SetupIntent

* refactor(billing): remove bank account support from CreatePremiumCloudHostedSubscriptionCommand

* refactor(billing): remove OrganizationBillingService.UpdatePaymentMethod dead code

* refactor(billing): remove ProviderBillingService.UpdatePaymentMethod

* refactor(billing): remove PremiumUserBillingService.UpdatePaymentMethod and UserService.ReplacePaymentMethodAsync

* refactor(billing): remove SubscriberService.UpdatePaymentSource and related dead code

* refactor(billing): update SubscriberService.GetPaymentSourceAsync to query Stripe by customer ID

Add Task 15a to plan - this was a missed requirement for updating
GetPaymentSourceAsync which still used the cache.

* refactor(billing): complete removal of PremiumUserBillingService.Finalize and UserService.SignUpPremiumAsync

* refactor(billing): remove ISetupIntentCache and SetupIntentDistributedCache

* chore: remove temporary planning documents

* chore: run dotnet format

* fix(billing): add MaxLength(50) to Provider gateway ID properties

* chore(db): add EF migrations for Provider gateway column lengths

* chore: run dotnet format

* chore: rename SQL migration for chronological order
This commit is contained in:
Alex Morask
2026-02-18 13:20:25 -06:00
committed by GitHub
parent 2ce98277b4
commit cfd5bedae0
69 changed files with 22548 additions and 1892 deletions

View File

@@ -9,11 +9,9 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities;
@@ -21,7 +19,6 @@ using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -51,7 +48,6 @@ public class ProviderBillingService(
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderUserRepository providerUserRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService)
: IProviderBillingService
@@ -518,6 +514,7 @@ public class ProviderBillingService(
}
var braintreeCustomerId = "";
var setupIntentId = "";
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
@@ -539,7 +536,7 @@ public class ProviderBillingService(
throw new BillingException();
}
await setupIntentCache.Set(provider.Id, setupIntent.Id);
setupIntentId = setupIntent.Id;
break;
}
case TokenizablePaymentMethodType.Card:
@@ -558,7 +555,15 @@ public class ProviderBillingService(
try
{
return await stripeAdapter.CreateCustomerAsync(options);
var customer = await stripeAdapter.CreateCustomerAsync(options);
if (!string.IsNullOrEmpty(setupIntentId))
{
await stripeAdapter.UpdateSetupIntentAsync(setupIntentId,
new SetupIntentUpdateOptions { Customer = customer.Id });
}
return customer;
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
{
@@ -577,12 +582,10 @@ public class ProviderBillingService(
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
case TokenizablePaymentMethodType.BankAccount when !string.IsNullOrEmpty(setupIntentId):
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.CancelSetupIntentAsync(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break;
}
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
@@ -635,17 +638,18 @@ public class ProviderBillingService(
});
}
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
Customer = customer.Id,
Expand = ["data.payment_method"]
});
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.GetSetupIntentAsync(setupIntentId,
new SetupIntentGetOptions { Expand = ["payment_method"] })
: null;
var hasUnverifiedBankAccount = setupIntents?.Any(si => si.IsUnverifiedBankAccount()) ?? false;
var usePaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
setupIntent?.IsUnverifiedBankAccount() == true;
hasUnverifiedBankAccount;
int? trialPeriodDays = provider.Type switch
{
@@ -699,19 +703,6 @@ public class ProviderBillingService(
}
}
public async Task UpdatePaymentMethod(
Provider provider,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation)
{
await Task.WhenAll(
subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource),
subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{
var (provider, updatedPlanConfigurations) = command;

View File

@@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
@@ -934,17 +933,11 @@ public class ProviderBillingServiceTests
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).CancelSetupIntentAsync("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
}
[Theory, BitAutoData]
@@ -1031,7 +1024,8 @@ public class ProviderBillingServiceTests
Assert.Equivalent(expected, actual);
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).UpdateSetupIntentAsync("setup_intent_id",
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == expected.Id));
}
[Theory, BitAutoData]
@@ -1532,15 +1526,12 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
const string setupIntentId = "seti_123";
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().GetSetupIntentAsync(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
sutProvider.GetDependency<IStripeAdapter>().ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.Expand.Contains("data.payment_method"))).Returns([
new SetupIntent
{
Id = setupIntentId,
Id = "seti_123",
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
@@ -1550,7 +1541,8 @@ public class ProviderBillingServiceTests
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
}
]);
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>

View File

@@ -1,7 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using OneOf;
@@ -11,10 +10,10 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class SetupIntentSucceededHandler(
ILogger<SetupIntentSucceededHandler> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IPushNotificationAdapter pushNotificationAdapter,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
IStripeEventService stripeEventService) : ISetupIntentSucceededHandler
{
@@ -27,23 +26,29 @@ public class SetupIntentSucceededHandler(
if (setupIntent is not
{
CustomerId: not null,
PaymentMethod.UsBankAccount: not null
})
{
logger.LogWarning("SetupIntent {SetupIntentId} has no customer ID or is not a US bank account", setupIntent.Id);
return;
}
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
if (subscriberId == null)
var organization = await organizationRepository.GetByGatewayCustomerIdAsync(setupIntent.CustomerId);
if (organization != null)
{
await SetPaymentMethodAsync(organization, setupIntent.PaymentMethod);
return;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
var provider = await providerRepository.GetByGatewayCustomerIdAsync(setupIntent.CustomerId);
if (provider != null)
{
await SetPaymentMethodAsync(provider, setupIntent.PaymentMethod);
return;
}
OneOf<Organization, Provider> entity = organization != null ? organization : provider!;
await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod);
logger.LogError("No organization or provider found for customer {CustomerId}", setupIntent.CustomerId);
}
private async Task SetPaymentMethodAsync(

View File

@@ -1,7 +1,4 @@
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Stripe;
@@ -9,10 +6,6 @@ namespace Bit.Billing.Services.Implementations;
public class StripeEventService(
GlobalSettings globalSettings,
ILogger<StripeEventService> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeFacade stripeFacade)
: IStripeEventService
{
@@ -117,7 +110,7 @@ public class StripeEventService(
(await GetCustomer(stripeEvent, true)).Metadata,
HandledStripeWebhook.SetupIntentSucceeded =>
await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
(await GetSetupIntent(stripeEvent, true, customerExpansion)).Customer?.Metadata,
_ => null
};
@@ -144,43 +137,6 @@ public class StripeEventService(
return customer?.Metadata;
}
async Task<Dictionary<string, string>?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent)
{
var setupIntent = await GetSetupIntent(localStripeEvent);
logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id);
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id);
if (subscriberId == null)
{
logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id);
return null;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id);
if (organization is { GatewayCustomerId: not null })
{
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id);
return organizationCustomer.Metadata;
}
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id);
if (provider is not { GatewayCustomerId: not null })
{
return null;
}
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id);
return providerCustomer.Metadata;
}
}
private static T Extract<T>(Event stripeEvent)

View File

@@ -1,4 +1,5 @@
using System.Net;
using System.ComponentModel.DataAnnotations;
using System.Net;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -33,7 +34,9 @@ public class Provider : ITableObject<Guid>, ISubscriber
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
public GatewayType? Gateway { get; set; }
[MaxLength(50)]
public string? GatewayCustomerId { get; set; }
[MaxLength(50)]
public string? GatewaySubscriptionId { get; set; }
public string? DiscountId { get; set; }

View File

@@ -9,6 +9,8 @@ namespace Bit.Core.Repositories;
public interface IOrganizationRepository : IRepository<Organization, Guid>
{
Task<Organization?> GetByGatewayCustomerIdAsync(string gatewayCustomerId);
Task<Organization?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId);
Task<Organization?> GetByIdentifierAsync(string identifier);
Task<ICollection<Organization>> GetManyByEnabledAsync();
Task<ICollection<Organization>> GetManyByUserIdAsync(Guid userId);

View File

@@ -8,6 +8,8 @@ namespace Bit.Core.AdminConsole.Repositories;
public interface IProviderRepository : IRepository<Provider, Guid>
{
Task<Provider?> GetByGatewayCustomerIdAsync(string gatewayCustomerId);
Task<Provider?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId);
Task<Provider?> GetByOrganizationIdAsync(Guid organizationId);
Task<ICollection<Provider>> SearchAsync(string name, string userEmail, int skip, int take);
Task<ICollection<ProviderAbility>> GetManyAbilitiesAsync();

View File

@@ -1,9 +0,0 @@
namespace Bit.Core.Billing.Caches;
public interface ISetupIntentCache
{
Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId);
Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId);
Task RemoveSetupIntentForSubscriber(Guid subscriberId);
Task Set(Guid subscriberId, string setupIntentId);
}

View File

@@ -1,50 +0,0 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Caches.Implementations;
public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache,
ILogger<SetupIntentDistributedCache> logger) : ISetupIntentCache
{
public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId)
{
var cacheKey = GetCacheKeyBySubscriberId(subscriberId);
return await distributedCache.GetStringAsync(cacheKey);
}
public async Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId)
{
var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
var value = await distributedCache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(value) && Guid.TryParse(value, out var subscriberId))
{
return subscriberId;
}
logger.LogError("Subscriber ID value ({Value}) cached for Setup Intent ({SetupIntentId}) is null or not a valid Guid", value, setupIntentId);
return null;
}
public async Task RemoveSetupIntentForSubscriber(Guid subscriberId)
{
var cacheKey = GetCacheKeyBySubscriberId(subscriberId);
await distributedCache.RemoveAsync(cacheKey);
}
public async Task Set(Guid subscriberId, string setupIntentId)
{
var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId);
var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
await Task.WhenAll(
distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId),
distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString()));
}
private static string GetCacheKeyBySetupIntentId(string setupIntentId) =>
$"subscriber_id_for_setup_intent_id_{setupIntentId}";
private static string GetCacheKeyBySubscriberId(Guid subscriberId) =>
$"setup_intent_id_for_subscriber_id_{subscriberId}";
}

View File

@@ -1,6 +1,4 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Caches.Implementations;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Commands;
using Bit.Core.Billing.Organizations.Queries;
@@ -29,7 +27,6 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ITaxService, TaxService>();
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
services.AddLicenseServices();
services.AddLicenseOperations();

View File

@@ -1,8 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Organizations.Services;
@@ -26,19 +24,6 @@ public interface IOrganizationBillingService
/// </example>
Task Finalize(OrganizationSale sale);
/// <summary>
/// Updates the provided <paramref name="organization"/>'s payment source and tax information.
/// If the <paramref name="organization"/> does not have a Stripe <see cref="Stripe.Customer"/>, this method will create one using the provided
/// <paramref name="tokenizedPaymentSource"/> and <paramref name="taxInformation"/>.
/// </summary>
/// <param name="organization">The <paramref name="organization"/> to update the payment source and tax information for.</param>
/// <param name="tokenizedPaymentSource">The tokenized payment source (ex. Credit Card) to attach to the <paramref name="organization"/>.</param>
/// <param name="taxInformation">The <paramref name="organization"/>'s updated tax information.</param>
Task UpdatePaymentMethod(
Organization organization,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation);
/// <summary>
/// Updates the subscription with new plan frequencies and changes the collection method to charge_automatically if a valid payment method exists.
/// Validates that the customer has a payment method attached before switching to automatic charging.

View File

@@ -1,15 +1,12 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -31,7 +28,6 @@ public class OrganizationBillingService(
ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService) : IOrganizationBillingService
@@ -54,33 +50,6 @@ public class OrganizationBillingService(
}
}
public async Task UpdatePaymentMethod(
Organization organization,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation)
{
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
var customer = await CreateCustomerAsync(organization,
new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation
});
organization.Gateway = GatewayType.Stripe;
organization.GatewayCustomerId = customer.Id;
await organizationRepository.ReplaceAsync(organization);
}
else
{
await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource);
await subscriberService.UpdateTaxInformation(organization, taxInformation);
await UpdateMissingPaymentMethodBehaviourAsync(organization);
}
}
public async Task UpdateSubscriptionPlanFrequency(
Organization organization, PlanType newPlanType)
{
@@ -203,6 +172,7 @@ public class OrganizationBillingService(
};
var braintreeCustomerId = "";
var setupIntentId = "";
if (customerSetup.IsBillable)
{
@@ -296,7 +266,7 @@ public class OrganizationBillingService(
throw new BillingException();
}
await setupIntentCache.Set(organization.Id, setupIntent.Id);
setupIntentId = setupIntent.Id;
break;
}
case PaymentMethodType.Card:
@@ -323,6 +293,12 @@ public class OrganizationBillingService(
{
var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions);
if (!string.IsNullOrEmpty(setupIntentId))
{
await stripeAdapter.UpdateSetupIntentAsync(setupIntentId,
new SetupIntentUpdateOptions { Customer = customer.Id });
}
organization.Gateway = GatewayType.Stripe;
organization.GatewayCustomerId = customer.Id;
await organizationRepository.ReplaceAsync(organization);
@@ -356,11 +332,6 @@ public class OrganizationBillingService(
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (customerSetup.TokenizedPaymentSource!.Type)
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
@@ -519,24 +490,6 @@ public class OrganizationBillingService(
return customer;
}
private async Task UpdateMissingPaymentMethodBehaviourAsync(Organization organization)
{
var subscription = await subscriberService.GetSubscriptionOrThrow(organization);
if (subscription.TrialSettings?.EndBehavior?.MissingPaymentMethod == StripeConstants.MissingPaymentMethodBehaviorOptions.Cancel)
{
var options = new SubscriptionUpdateOptions
{
TrialSettings = new SubscriptionTrialSettingsOptions
{
EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions
{
MissingPaymentMethod = StripeConstants.MissingPaymentMethodBehaviorOptions.CreateInvoice
}
}
};
await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, options);
}
}
#endregion
}

View File

@@ -1,5 +1,4 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
@@ -28,7 +27,6 @@ public class UpdatePaymentMethodCommand(
IBraintreeService braintreeService,
IGlobalSettings globalSettings,
ILogger<UpdatePaymentMethodCommand> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : BaseBillingCommand<UpdatePaymentMethodCommand>(logger), IUpdatePaymentMethodCommand
{
@@ -95,9 +93,10 @@ public class UpdatePaymentMethodCommand(
var setupIntent = setupIntents.First();
await setupIntentCache.Set(subscriber.Id, setupIntent.Id);
await stripeAdapter.UpdateSetupIntentAsync(setupIntent.Id,
new SetupIntentUpdateOptions { Customer = customer.Id });
_logger.LogInformation("{Command}: Successfully cached Setup Intent ({SetupIntentId}) for subscriber ({SubscriberID})", CommandName, setupIntent.Id, subscriber.Id);
_logger.LogInformation("{Command}: Successfully linked Setup Intent ({SetupIntentId}) to customer ({CustomerId}) for subscriber ({SubscriberID})", CommandName, setupIntent.Id, customer.Id, subscriber.Id);
await UnlinkBraintreeCustomerAsync(customer);

View File

@@ -1,5 +1,4 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@@ -16,7 +15,6 @@ public interface IGetPaymentMethodQuery
public class GetPaymentMethodQuery(
IBraintreeService braintreeService,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetPaymentMethodQuery
{
@@ -39,19 +37,17 @@ public class GetPaymentMethodQuery(
}
// Then check for a bank account pending verification
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (!string.IsNullOrEmpty(setupIntentId))
var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
Customer = customer.Id,
Expand = ["data.payment_method"]
});
if (setupIntent.IsUnverifiedBankAccount())
{
return MaskedPaymentMethod.From(setupIntent);
}
var unverifiedBankAccount = setupIntents?.FirstOrDefault(si => si.IsUnverifiedBankAccount());
if (unverifiedBankAccount != null)
{
return MaskedPaymentMethod.From(unverifiedBankAccount);
}
// Then check the default payment method

View File

@@ -1,5 +1,4 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@@ -15,21 +14,20 @@ public interface IHasPaymentMethodQuery
}
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 false;
}
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(customer.Id);
return
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
@@ -37,21 +35,14 @@ public class HasPaymentMethodQuery(
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
}
private async Task<bool> HasUnverifiedBankAccountAsync(
ISubscriber subscriber)
private async Task<bool> HasUnverifiedBankAccountAsync(string customerId)
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
return false;
}
var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
Customer = customerId,
Expand = ["data.payment_method"]
});
return setupIntent.IsUnverifiedBankAccount();
return setupIntents?.Any(si => si.IsUnverifiedBankAccount()) ?? false;
}
}

View File

@@ -1,5 +1,4 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
@@ -52,7 +51,6 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
IBraintreeGateway braintreeGateway,
IBraintreeService braintreeService,
IGlobalSettings globalSettings,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserService userService,
@@ -218,21 +216,6 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var tokenizedPaymentMethod = paymentMethod.AsTokenized;
switch (tokenizedPaymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token }))
.FirstOrDefault();
if (setupIntent == null)
{
_logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id);
throw new BillingException();
}
await setupIntentCache.Set(user.Id, setupIntent.Id);
break;
}
case TokenizablePaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token;
@@ -267,11 +250,6 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (tokenizedPaymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
break;
}
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);

View File

@@ -4,11 +4,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Tax.Models;
using Stripe;
namespace Bit.Core.Billing.Providers.Services;
@@ -101,17 +99,6 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription(
Provider provider);
/// <summary>
/// Updates the <paramref name="provider"/>'s payment source and tax information and then sets their subscription's collection_method to be "charge_automatically".
/// </summary>
/// <param name="provider">The <paramref name="provider"/> to update the payment source and tax information for.</param>
/// <param name="tokenizedPaymentSource">The tokenized payment source (ex. Credit Card) to attach to the <paramref name="provider"/>.</param>
/// <param name="taxInformation">The <paramref name="provider"/>'s updated tax information.</param>
Task UpdatePaymentMethod(
Provider provider,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
/// <summary>

View File

@@ -1,39 +1,8 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Services;
public interface IPremiumUserBillingService
{
Task Credit(User user, decimal amount);
/// <summary>
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="User"/> using the provided <paramref name="sale"/>.</para>
/// <para>
/// The method first checks to see if the
/// provided <see cref="PremiumUserSale.User"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="User.GatewayCustomerId"/>.
/// If it doesn't, the method creates one using the <paramref name="sale"/>'s <see cref="PremiumUserSale.CustomerSetup"/>. The method then creates a Stripe <see cref="Stripe.Subscription"/>
/// for the created or existing customer while appending the provided <paramref name="sale"/>'s <see cref="PremiumUserSale.Storage"/>.
/// </para>
/// </summary>
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the premium user.</param>
/// <example>
/// <code>
/// var sale = PremiumUserSale.From(
/// user,
/// paymentMethodType,
/// paymentMethodToken,
/// taxInfo,
/// storage);
/// await premiumUserBillingService.Finalize(sale);
/// </code>
/// </example>
Task Finalize(PremiumUserSale sale);
Task UpdatePaymentMethod(
User user,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation);
}

View File

@@ -47,6 +47,7 @@ public interface IStripeAdapter
Task<List<SetupIntent>> ListSetupIntentsAsync(SetupIntentListOptions options);
Task CancelSetupIntentAsync(string id, SetupIntentCancelOptions options = null);
Task<SetupIntent> GetSetupIntentAsync(string id, SetupIntentGetOptions options = null);
Task<SetupIntent> UpdateSetupIntentAsync(string id, SetupIntentUpdateOptions options = null);
Task<Price> GetPriceAsync(string id, PriceGetOptions options = null);
Task<Coupon> GetCouponAsync(string couponId, CouponGetOptions options = null);
Task<List<Product>> ListProductsAsync(ProductListOptions options = null);

View File

@@ -4,7 +4,6 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe;
namespace Bit.Core.Billing.Services;
@@ -105,18 +104,6 @@ public interface ISubscriberService
/// <param name="subscriber">The subscriber to remove the saved payment source for.</param>
Task RemovePaymentSource(ISubscriber subscriber);
/// <summary>
/// Updates the payment source for the provided <paramref name="subscriber"/> using the <paramref name="tokenizedPaymentSource"/>.
/// The following types are supported: [<see cref="PaymentMethodType.Card"/>, <see cref="PaymentMethodType.BankAccount"/>, <see cref="PaymentMethodType.PayPal"/>].
/// For each type, updating the payment source will attempt to establish a new payment source using the token in the <see cref="TokenizedPaymentSource"/>. Then, it will
/// remove the exising payment source(s) linked to the subscriber's customer.
/// </summary>
/// <param name="subscriber">The subscriber to update the payment method for.</param>
/// <param name="tokenizedPaymentSource">A DTO representing a tokenized payment method.</param>
Task UpdatePaymentSource(
ISubscriber subscriber,
TokenizedPaymentSource tokenizedPaymentSource);
/// <summary>
/// Updates the tax information for the provided <paramref name="subscriber"/>.
/// </summary>

View File

@@ -1,37 +1,16 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
using static Utilities;
public class PremiumUserBillingService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<PremiumUserBillingService> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserRepository userRepository,
IPricingClient pricingClient) : IPremiumUserBillingService
IUserRepository userRepository) : IPremiumUserBillingService
{
public async Task Credit(User user, decimal amount)
{
@@ -83,309 +62,4 @@ public class PremiumUserBillingService(
await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
}
}
public async Task Finalize(PremiumUserSale sale)
{
var (user, customerSetup, storage) = sale;
List<string> expand = ["tax"];
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, customerSetup)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = expand });
/*
* If the customer was previously set up with credit, which does not require a billing location,
* we need to update the customer on the fly before we start the subscription.
*/
customer = await ReconcileBillingLocationAsync(customer, customerSetup.TaxInformation);
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, storage);
switch (customerSetup.TokenizedPaymentSource)
{
case { Type: PaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not PaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
}
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + (storage ?? 0));
await userRepository.ReplaceAsync(user);
}
public async Task UpdatePaymentMethod(
User user,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation)
{
if (string.IsNullOrEmpty(user.GatewayCustomerId))
{
var customer = await CreateCustomerAsync(user,
new CustomerSetup { TokenizedPaymentSource = tokenizedPaymentSource, TaxInformation = taxInformation });
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
await userRepository.ReplaceAsync(user);
}
else
{
await subscriberService.UpdatePaymentSource(user, tokenizedPaymentSource);
await subscriberService.UpdateTaxInformation(user, taxInformation);
}
}
private async Task<Customer> CreateCustomerAsync(
User user,
CustomerSetup customerSetup)
{
/*
* Creating a Customer via the adding of a payment method or the purchasing of a subscription requires
* an actual payment source. The only time this is not the case is when the Customer is created when the
* User purchases credit.
*/
if (customerSetup.TokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
logger.LogError(
"Cannot create customer for user ({UserID}) without a valid payment source", user.Id);
throw new BillingException();
}
if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
{
logger.LogError(
"Cannot create customer for user ({UserID}) without valid tax information", user.Id);
throw new BillingException();
}
var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = new AddressOptions
{
Line1 = customerSetup.TaxInformation.Line1,
Line2 = customerSetup.TaxInformation.Line2,
City = customerSetup.TaxInformation.City,
PostalCode = customerSetup.TaxInformation.PostalCode,
State = customerSetup.TaxInformation.State,
Country = customerSetup.TaxInformation.Country
},
Description = user.Name,
Email = user.Email,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = user.SubscriberType(),
Value = subscriberName.Length <= 30
? subscriberName
: subscriberName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
["region"] = globalSettings.BaseServiceUri.CloudRegion,
["userId"] = user.Id.ToString()
},
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
}
};
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethodType)
{
case PaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
.FirstOrDefault();
if (setupIntent == null)
{
logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id);
throw new BillingException();
}
await setupIntentCache.Set(user.Id, setupIntent.Id);
break;
}
case PaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = paymentMethodToken;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
break;
}
case PaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethodToken);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethodType.ToString());
throw new BillingException();
}
}
try
{
return await stripeAdapter.CreateCustomerAsync(customerCreateOptions);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
await Revert();
throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
}
catch
{
await Revert();
throw;
}
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (customerSetup.TokenizedPaymentSource!.Type)
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
}
}
private async Task<Subscription> CreateSubscriptionAsync(
Guid userId,
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage)
{
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = premiumPlan.Seat.StripePriceId,
Quantity = 1
}
};
if (storage is > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = premiumPlan.Storage.StripePriceId,
Quantity = storage
});
}
var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false;
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
["userId"] = userId.ToString()
},
PaymentBehavior = usingPayPal
? StripeConstants.PaymentBehavior.DefaultIncomplete
: null,
OffSession = true
};
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
if (usingPayPal)
{
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
}
return subscription;
}
private async Task<Customer> ReconcileBillingLocationAsync(
Customer customer,
TaxInformation taxInformation)
{
if (customer is { Address: { Country: not null and not "", PostalCode: not null and not "" } })
{
return customer;
}
var options = new CustomerUpdateOptions
{
Address = new AddressOptions
{
Line1 = taxInformation.Line1,
Line2 = taxInformation.Line2,
City = taxInformation.City,
PostalCode = taxInformation.PostalCode,
State = taxInformation.State,
Country = taxInformation.Country
},
Expand = ["tax"],
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
}
};
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
}
}

View File

@@ -199,6 +199,9 @@ public class StripeAdapter : IStripeAdapter
public Task<SetupIntent> GetSetupIntentAsync(string id, SetupIntentGetOptions options = null) =>
_setupIntentService.GetAsync(id, options);
public Task<SetupIntent> UpdateSetupIntentAsync(string id, SetupIntentUpdateOptions options = null) =>
_setupIntentService.UpdateAsync(id, options);
/*******************
** MISCELLANEOUS **
*******************/

View File

@@ -4,7 +4,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
@@ -35,7 +34,6 @@ public class SubscriberService(
ILogger<SubscriberService> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ITaxService taxService,
IUserRepository userRepository) : ISubscriberService
@@ -338,7 +336,7 @@ public class SubscriberService(
Expand = ["default_source", "invoice_settings.default_payment_method"]
});
return await GetPaymentSourceAsync(subscriber.Id, customer);
return await GetPaymentSourceAsync(customer);
}
public async Task<Subscription> GetSubscription(
@@ -507,130 +505,6 @@ public class SubscriberService(
}
}
public async Task UpdatePaymentSource(
ISubscriber subscriber,
TokenizedPaymentSource tokenizedPaymentSource)
{
ArgumentNullException.ThrowIfNull(subscriber);
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
var customer = await GetCustomerOrThrow(subscriber, customerGetOptions);
var (type, token) = tokenizedPaymentSource;
if (string.IsNullOrEmpty(token))
{
logger.LogError("Updated payment method for ({SubscriberID}) must contain a token", subscriber.Id);
throw new BillingException();
}
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (type)
{
case PaymentMethodType.BankAccount:
{
var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
PaymentMethod = token
});
// Find the setup intent for the incoming payment method token.
var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod;
if (setupIntentsForUpdatedPaymentMethod.Count != 1)
{
logger.LogError("There were more than 1 setup intents for subscriber's ({SubscriberID}) updated payment method", subscriber.Id);
throw new BillingException();
}
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First();
// Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later.
await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id);
// Remove the customer's other attached Stripe payment methods.
var postProcessing = new List<Task>
{
RemoveStripePaymentMethodsAsync(customer),
RemoveBraintreeCustomerIdAsync(customer)
};
await Task.WhenAll(postProcessing);
break;
}
case PaymentMethodType.Card:
{
// Remove the customer's other attached Stripe payment methods.
await RemoveStripePaymentMethodsAsync(customer);
// Attach the incoming payment method.
await stripeAdapter.AttachPaymentMethodAsync(token,
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
var metadata = customer.Metadata;
if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value))
{
metadata[BraintreeCustomerIdOldKey] = value;
metadata[BraintreeCustomerIdKey] = null;
}
// Set the customer's default payment method in Stripe and remove their Braintree customer ID.
await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = token
},
Metadata = metadata
});
break;
}
case PaymentMethodType.PayPal:
{
string braintreeCustomerId;
if (customer.Metadata != null)
{
var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out braintreeCustomerId);
if (hasBraintreeCustomerId)
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
if (braintreeCustomer == null)
{
logger.LogError("Failed to retrieve Braintree customer ({BraintreeCustomerId}) when updating payment method for subscriber ({SubscriberID})", braintreeCustomerId, subscriber.Id);
throw new BillingException();
}
await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token);
return;
}
}
braintreeCustomerId = await CreateBraintreeCustomer(subscriber, token);
await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId);
break;
}
default:
{
logger.LogError("Cannot update subscriber's ({SubscriberID}) payment method to type ({PaymentMethodType}) as it is not supported", subscriber.Id, type.ToString());
throw new BillingException();
}
}
}
public async Task UpdateTaxInformation(
ISubscriber subscriber,
TaxInformation taxInformation)
@@ -819,23 +693,7 @@ public class SubscriberService(
#region Shared Utilities
private async Task AddBraintreeCustomerIdAsync(
Customer customer,
string braintreeCustomerId)
{
var metadata = customer.Metadata ?? new Dictionary<string, string>();
metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = metadata
});
}
private async Task<PaymentSource> GetPaymentSourceAsync(
Guid subscriberId,
Customer customer)
private async Task<PaymentSource> GetPaymentSourceAsync(Customer customer)
{
if (customer.Metadata != null)
{
@@ -858,108 +716,17 @@ public class SubscriberService(
/*
* attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account".
* We store the ID of this SetupIntent in the cache when we originally update the payment method.
* Query Stripe for SetupIntents associated with this customer.
*/
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriberId);
if (string.IsNullOrEmpty(setupIntentId))
var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
return null;
}
var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
Customer = customer.Id,
Expand = ["data.payment_method"]
});
return PaymentSource.From(setupIntent);
}
var unverifiedBankAccount = setupIntents?.FirstOrDefault(si => si.IsUnverifiedBankAccount());
private async Task RemoveBraintreeCustomerIdAsync(
Customer customer)
{
var metadata = customer.Metadata ?? new Dictionary<string, string>();
if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value))
{
metadata[BraintreeCustomerIdOldKey] = value;
metadata[BraintreeCustomerIdKey] = null;
await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = metadata
});
}
}
private async Task RemoveStripePaymentMethodsAsync(
Customer customer)
{
if (customer.Sources != null && customer.Sources.Any())
{
foreach (var source in customer.Sources)
{
switch (source)
{
case BankAccount:
await stripeAdapter.DeleteBankAccountAsync(customer.Id, source.Id);
break;
case Card:
await stripeAdapter.DeleteCardAsync(customer.Id, source.Id);
break;
}
}
}
var paymentMethods = await stripeAdapter.ListCustomerPaymentMethodsAsync(customer.Id);
await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.DetachPaymentMethodAsync(pm.Id)));
}
private async Task ReplaceBraintreePaymentMethodAsync(
Braintree.Customer customer,
string defaultPaymentMethodToken)
{
var existingDefaultPaymentMethod = customer.DefaultPaymentMethod;
var createPaymentMethodResult = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest
{
CustomerId = customer.Id,
PaymentMethodNonce = defaultPaymentMethodToken
});
if (!createPaymentMethodResult.IsSuccess())
{
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Creation of new payment method failed | Error: {Error}", customer.Id, createPaymentMethodResult.Message);
throw new BillingException();
}
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
customer.Id,
new CustomerRequest { DefaultPaymentMethodToken = createPaymentMethodResult.Target.Token });
if (!updateCustomerResult.IsSuccess())
{
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Customer update failed | Error: {Error}",
customer.Id, updateCustomerResult.Message);
await braintreeGateway.PaymentMethod.DeleteAsync(createPaymentMethodResult.Target.Token);
throw new BillingException();
}
if (existingDefaultPaymentMethod != null)
{
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
if (!deletePaymentMethodResult.IsSuccess())
{
logger.LogWarning(
"Failed to delete replaced payment method for Braintree customer ({ID}) - outdated payment method still exists | Error: {Error}",
customer.Id, deletePaymentMethodResult.Message);
}
}
return unverifiedBankAccount != null ? PaymentSource.From(unverifiedBankAccount) : null;
}
#endregion

View File

@@ -10,6 +10,8 @@ namespace Bit.Core.Repositories;
public interface IUserRepository : IRepository<User, Guid>
{
Task<User?> GetByGatewayCustomerIdAsync(string gatewayCustomerId);
Task<User?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId);
Task<User?> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetManyByEmailsAsync(IEnumerable<string> emails);
Task<User?> GetBySsoUserAsync(string externalId, Guid? organizationId);

View File

@@ -41,12 +41,8 @@ public interface IUserService
Task<IdentityResult> DeleteAsync(User user);
Task<IdentityResult> DeleteAsync(User user, string token);
Task SendDeleteConfirmationAsync(string email);
Task<Tuple<bool, string>> SignUpPremiumAsync(User user, string paymentToken,
PaymentMethodType paymentMethodType, short additionalStorageGb, UserLicense license,
TaxInfo taxInfo);
Task UpdateLicenseAsync(User user, UserLicense license);
Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb);
Task ReplacePaymentMethodAsync(User user, string paymentToken, PaymentMethodType paymentMethodType, TaxInfo taxInfo);
Task CancelPremiumAsync(User user, bool? endOfPeriod = null);
Task ReinstatePremiumAsync(User user);
Task EnablePremiumAsync(Guid userId, DateTime? expirationDate);

View File

@@ -15,13 +15,10 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Premium.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -68,7 +65,6 @@ public class UserService : UserManager<User>, IUserService
private readonly IProviderUserRepository _providerUserRepository;
private readonly IStripeSyncService _stripeSyncService;
private readonly IFeatureService _featureService;
private readonly IPremiumUserBillingService _premiumUserBillingService;
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IDistributedCache _distributedCache;
@@ -105,7 +101,6 @@ public class UserService : UserManager<User>, IUserService
IProviderUserRepository providerUserRepository,
IStripeSyncService stripeSyncService,
IFeatureService featureService,
IPremiumUserBillingService premiumUserBillingService,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IDistributedCache distributedCache,
@@ -146,7 +141,6 @@ public class UserService : UserManager<User>, IUserService
_providerUserRepository = providerUserRepository;
_stripeSyncService = stripeSyncService;
_featureService = featureService;
_premiumUserBillingService = premiumUserBillingService;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_distributedCache = distributedCache;
@@ -742,78 +736,6 @@ public class UserService : UserManager<User>, IUserService
return true;
}
public async Task<Tuple<bool, string>> SignUpPremiumAsync(User user, string paymentToken,
PaymentMethodType paymentMethodType, short additionalStorageGb, UserLicense license,
TaxInfo taxInfo)
{
if (user.Premium)
{
throw new BadRequestException("Already a premium user.");
}
if (additionalStorageGb < 0)
{
throw new BadRequestException("You can't subtract storage!");
}
string paymentIntentClientSecret = null;
IStripePaymentService paymentService = null;
if (_globalSettings.SelfHosted)
{
if (license == null || !_licenseService.VerifyLicense(license))
{
throw new BadRequestException("Invalid license.");
}
var claimsPrincipal = _licenseService.GetClaimsPrincipalFromLicense(license);
if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage))
{
throw new BadRequestException(exceptionMessage);
}
var dir = $"{_globalSettings.LicenseDirectory}/user";
Directory.CreateDirectory(dir);
using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json"));
await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented);
}
else
{
var sale = PremiumUserSale.From(user, paymentMethodType, paymentToken, taxInfo, additionalStorageGb);
await _premiumUserBillingService.Finalize(sale);
}
user.Premium = true;
user.RevisionDate = DateTime.UtcNow;
if (_globalSettings.SelfHosted)
{
user.MaxStorageGb = Constants.SelfHostedMaxStorageGb;
user.LicenseKey = license.LicenseKey;
user.PremiumExpirationDate = license.Expires;
}
else
{
user.LicenseKey = CoreHelpers.SecureRandomString(20);
}
try
{
await SaveUserAsync(user);
await _pushService.PushSyncVaultAsync(user.Id);
}
catch when (!_globalSettings.SelfHosted)
{
await paymentService.CancelAndRecoverChargesAsync(user);
throw;
}
return new Tuple<bool, string>(string.IsNullOrWhiteSpace(paymentIntentClientSecret),
paymentIntentClientSecret);
}
public async Task UpdateLicenseAsync(User user, UserLicense license)
{
if (!_globalSettings.SelfHosted)
@@ -883,20 +805,6 @@ public class UserService : UserManager<User>, IUserService
return secret;
}
public async Task ReplacePaymentMethodAsync(User user, string paymentToken, PaymentMethodType paymentMethodType, TaxInfo taxInfo)
{
if (paymentToken.StartsWith("btok_"))
{
throw new BadRequestException("Invalid token.");
}
var tokenizedPaymentSource = new TokenizedPaymentSource(paymentMethodType, paymentToken);
var taxInformation = TaxInformation.From(taxInfo);
await _premiumUserBillingService.UpdatePaymentMethod(user, tokenizedPaymentSource, taxInformation);
await SaveUserAsync(user);
}
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null)
{
var eop = endOfPeriod.GetValueOrDefault(true);

View File

@@ -445,42 +445,42 @@ The persistent cache is accessed via keyed service injection and is optimized fo
The persistent `IDistributedCache` service is appropriate for workflow state that spans multiple requests and needs automatic TTL cleanup.
```csharp
public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")] IDistributedCache distributedCache) : ISetupIntentCache
public class PaymentWorkflowCache(
[FromKeyedServices("persistent")] IDistributedCache distributedCache) : IPaymentWorkflowCache
{
public async Task Set(Guid subscriberId, string setupIntentId)
public async Task SetPaymentSessionAsync(Guid userId, string sessionId)
{
// Bidirectional mapping for payment flow
var bySubscriberIdCacheKey = $"setup_intent_id_for_subscriber_id_{subscriberId}";
var bySetupIntentIdCacheKey = $"subscriber_id_for_setup_intent_id_{setupIntentId}";
var byUserIdCacheKey = $"payment_session_for_user_{userId}";
var bySessionIdCacheKey = $"user_for_payment_session_{sessionId}";
// Note: No explicit TTL set here. Cosmos DB uses container-level TTL for automatic cleanup.
// In cloud, Cosmos TTL handles expiration. In self-hosted, the cache backend manages TTL.
await Task.WhenAll(
distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId),
distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString()));
distributedCache.SetStringAsync(byUserIdCacheKey, sessionId),
distributedCache.SetStringAsync(bySessionIdCacheKey, userId.ToString()));
}
public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId)
public async Task<string?> GetPaymentSessionForUserAsync(Guid userId)
{
var cacheKey = $"setup_intent_id_for_subscriber_id_{subscriberId}";
var cacheKey = $"payment_session_for_user_{userId}";
return await distributedCache.GetStringAsync(cacheKey);
}
public async Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId)
public async Task<Guid?> GetUserForPaymentSessionAsync(string sessionId)
{
var cacheKey = $"subscriber_id_for_setup_intent_id_{setupIntentId}";
var cacheKey = $"user_for_payment_session_{sessionId}";
var value = await distributedCache.GetStringAsync(cacheKey);
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId))
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var userId))
{
return null;
}
return subscriberId;
return userId;
}
public async Task RemoveSetupIntentForSubscriber(Guid subscriberId)
public async Task RemovePaymentSessionForUserAsync(Guid userId)
{
var cacheKey = $"setup_intent_id_for_subscriber_id_{subscriberId}";
var cacheKey = $"payment_session_for_user_{userId}";
await distributedCache.RemoveAsync(cacheKey);
}
}

View File

@@ -27,6 +27,32 @@ public class OrganizationRepository : Repository<Organization, Guid>, IOrganizat
_logger = logger;
}
public async Task<Organization?> GetByGatewayCustomerIdAsync(string gatewayCustomerId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Organization>(
"[dbo].[Organization_ReadByGatewayCustomerId]",
new { GatewayCustomerId = gatewayCustomerId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<Organization?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Organization>(
"[dbo].[Organization_ReadByGatewaySubscriptionId]",
new { GatewaySubscriptionId = gatewaySubscriptionId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<Organization?> GetByIdentifierAsync(string identifier)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@@ -21,6 +21,32 @@ public class ProviderRepository : Repository<Provider, Guid>, IProviderRepositor
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<Provider?> GetByGatewayCustomerIdAsync(string gatewayCustomerId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Provider>(
"[dbo].[Provider_ReadByGatewayCustomerId]",
new { GatewayCustomerId = gatewayCustomerId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<Provider?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Provider>(
"[dbo].[Provider_ReadByGatewaySubscriptionId]",
new { GatewaySubscriptionId = gatewaySubscriptionId },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<Provider?> GetByOrganizationIdAsync(Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@@ -35,6 +35,34 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
return user;
}
public async Task<User?> GetByGatewayCustomerIdAsync(string gatewayCustomerId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<User>(
"[dbo].[User_ReadByGatewayCustomerId]",
new { GatewayCustomerId = gatewayCustomerId },
commandType: CommandType.StoredProcedure);
UnprotectData(results);
return results.FirstOrDefault();
}
}
public async Task<User?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<User>(
"[dbo].[User_ReadByGatewaySubscriptionId]",
new { GatewaySubscriptionId = gatewaySubscriptionId },
commandType: CommandType.StoredProcedure);
UnprotectData(results);
return results.FirstOrDefault();
}
}
public async Task<User?> GetByEmailAsync(string email)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@@ -20,6 +20,9 @@ public class OrganizationEntityTypeConfiguration : IEntityTypeConfiguration<Orga
builder.HasIndex(o => new { o.Id, o.Enabled }),
o => new { o.UseTotp, o.UsersGetPremium });
builder.HasIndex(o => o.GatewayCustomerId);
builder.HasIndex(o => o.GatewaySubscriptionId);
builder.ToTable(nameof(Organization));
}
}

View File

@@ -0,0 +1,20 @@
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
public class ProviderEntityTypeConfiguration : IEntityTypeConfiguration<Provider>
{
public void Configure(EntityTypeBuilder<Provider> builder)
{
builder
.Property(p => p.Id)
.ValueGeneratedNever();
builder.HasIndex(p => p.GatewayCustomerId);
builder.HasIndex(p => p.GatewaySubscriptionId);
builder.ToTable(nameof(Provider));
}
}

View File

@@ -31,6 +31,30 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
_logger = logger;
}
public async Task<Core.AdminConsole.Entities.Organization> GetByGatewayCustomerIdAsync(string gatewayCustomerId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var organization = await GetDbSet(dbContext)
.Where(e => e.GatewayCustomerId == gatewayCustomerId)
.FirstOrDefaultAsync();
return organization;
}
}
public async Task<Core.AdminConsole.Entities.Organization> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var organization = await GetDbSet(dbContext)
.Where(e => e.GatewaySubscriptionId == gatewaySubscriptionId)
.FirstOrDefaultAsync();
return organization;
}
}
public async Task<Core.AdminConsole.Entities.Organization> GetByIdentifierAsync(string identifier)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

@@ -29,6 +29,30 @@ public class ProviderRepository : Repository<Provider, Models.Provider.Provider,
await base.DeleteAsync(provider);
}
public async Task<Provider> GetByGatewayCustomerIdAsync(string gatewayCustomerId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var provider = await GetDbSet(dbContext)
.Where(e => e.GatewayCustomerId == gatewayCustomerId)
.FirstOrDefaultAsync();
return Mapper.Map<Provider>(provider);
}
}
public async Task<Provider> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var provider = await GetDbSet(dbContext)
.Where(e => e.GatewaySubscriptionId == gatewaySubscriptionId)
.FirstOrDefaultAsync();
return Mapper.Map<Provider>(provider);
}
}
public async Task<Provider> GetByOrganizationIdAsync(Guid organizationId)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

@@ -21,6 +21,9 @@ public class UserEntityTypeConfiguration : IEntityTypeConfiguration<User>
.HasIndex(u => new { u.Premium, u.PremiumExpirationDate, u.RenewalReminderDate })
.IsClustered(false);
builder.HasIndex(u => u.GatewayCustomerId);
builder.HasIndex(u => u.GatewaySubscriptionId);
builder.ToTable(nameof(User));
}
}

View File

@@ -20,6 +20,28 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Users)
{ }
public async Task<Core.Entities.User?> GetByGatewayCustomerIdAsync(string gatewayCustomerId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entity = await GetDbSet(dbContext)
.FirstOrDefaultAsync(e => e.GatewayCustomerId == gatewayCustomerId);
return Mapper.Map<Core.Entities.User>(entity);
}
}
public async Task<Core.Entities.User?> GetByGatewaySubscriptionIdAsync(string gatewaySubscriptionId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entity = await GetDbSet(dbContext)
.FirstOrDefaultAsync(e => e.GatewaySubscriptionId == gatewaySubscriptionId);
return Mapper.Map<Core.Entities.User>(entity);
}
}
public async Task<Core.Entities.User?> GetByEmailAsync(string email)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[Organization_ReadByGatewayCustomerId]
@GatewayCustomerId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[OrganizationView]
WHERE
[GatewayCustomerId] = @GatewayCustomerId
END

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[Organization_ReadByGatewaySubscriptionId]
@GatewaySubscriptionId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[OrganizationView]
WHERE
[GatewaySubscriptionId] = @GatewaySubscriptionId
END

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[Provider_ReadByGatewayCustomerId]
@GatewayCustomerId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ProviderView]
WHERE
[GatewayCustomerId] = @GatewayCustomerId
END

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[Provider_ReadByGatewaySubscriptionId]
@GatewaySubscriptionId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ProviderView]
WHERE
[GatewaySubscriptionId] = @GatewaySubscriptionId
END

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[User_ReadByGatewayCustomerId]
@GatewayCustomerId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[UserView]
WHERE
[GatewayCustomerId] = @GatewayCustomerId
END

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[User_ReadByGatewaySubscriptionId]
@GatewaySubscriptionId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[UserView]
WHERE
[GatewaySubscriptionId] = @GatewaySubscriptionId
END

View File

@@ -76,3 +76,13 @@ GO
CREATE UNIQUE NONCLUSTERED INDEX [IX_Organization_Identifier]
ON [dbo].[Organization]([Identifier] ASC)
WHERE [Identifier] IS NOT NULL;
GO
CREATE NONCLUSTERED INDEX [IX_Organization_GatewayCustomerId]
ON [dbo].[Organization]([GatewayCustomerId])
WHERE [GatewayCustomerId] IS NOT NULL;
GO
CREATE NONCLUSTERED INDEX [IX_Organization_GatewaySubscriptionId]
ON [dbo].[Organization]([GatewaySubscriptionId])
WHERE [GatewaySubscriptionId] IS NOT NULL;

View File

@@ -21,3 +21,13 @@
[DiscountId] VARCHAR (50) NULL,
CONSTRAINT [PK_Provider] PRIMARY KEY CLUSTERED ([Id] ASC)
);
GO
CREATE NONCLUSTERED INDEX [IX_Provider_GatewayCustomerId]
ON [dbo].[Provider]([GatewayCustomerId])
WHERE [GatewayCustomerId] IS NOT NULL;
GO
CREATE NONCLUSTERED INDEX [IX_Provider_GatewaySubscriptionId]
ON [dbo].[Provider]([GatewaySubscriptionId])
WHERE [GatewaySubscriptionId] IS NOT NULL;

View File

@@ -62,3 +62,12 @@ GO
CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain]
ON [dbo].[User]([Id] ASC, [Email] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_User_GatewayCustomerId]
ON [dbo].[User]([GatewayCustomerId])
WHERE [GatewayCustomerId] IS NOT NULL;
GO
CREATE NONCLUSTERED INDEX [IX_User_GatewaySubscriptionId]
ON [dbo].[User]([GatewaySubscriptionId])
WHERE [GatewaySubscriptionId] IS NOT NULL;

View File

@@ -3,9 +3,9 @@ using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
@@ -18,28 +18,28 @@ public class SetupIntentSucceededHandlerTests
private static readonly Event _mockEvent = new() { Id = "evt_test", Type = "setup_intent.succeeded" };
private static readonly string[] _expand = ["payment_method"];
private readonly ILogger<SetupIntentSucceededHandler> _logger;
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderRepository _providerRepository;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
private readonly ISetupIntentCache _setupIntentCache;
private readonly IStripeAdapter _stripeAdapter;
private readonly IStripeEventService _stripeEventService;
private readonly SetupIntentSucceededHandler _handler;
public SetupIntentSucceededHandlerTests()
{
_logger = Substitute.For<ILogger<SetupIntentSucceededHandler>>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_providerRepository = Substitute.For<IProviderRepository>();
_pushNotificationAdapter = Substitute.For<IPushNotificationAdapter>();
_setupIntentCache = Substitute.For<ISetupIntentCache>();
_stripeAdapter = Substitute.For<IStripeAdapter>();
_stripeEventService = Substitute.For<IStripeEventService>();
_handler = new SetupIntentSucceededHandler(
_logger,
_organizationRepository,
_providerRepository,
_pushNotificationAdapter,
_setupIntentCache,
_stripeAdapter,
_stripeEventService);
}
@@ -60,7 +60,7 @@ public class SetupIntentSucceededHandlerTests
await _handler.HandleAsync(_mockEvent);
// Assert
await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any<string>());
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByGatewayCustomerIdAsync(Arg.Any<string>());
await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync(
Arg.Any<string>(), Arg.Any<PaymentMethodAttachOptions>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Organization>());
@@ -68,10 +68,10 @@ public class SetupIntentSucceededHandlerTests
}
[Fact]
public async Task HandleAsync_NoSubscriberIdInCache_Returns()
public async Task HandleAsync_NoCustomerIdOnSetupIntent_Returns()
{
// Arrange
var setupIntent = CreateSetupIntent();
var setupIntent = CreateSetupIntent(customerId: null);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -79,8 +79,35 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns((Guid?)null);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByGatewayCustomerIdAsync(Arg.Any<string>());
await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync(
Arg.Any<string>(), Arg.Any<PaymentMethodAttachOptions>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Organization>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
}
[Fact]
public async Task HandleAsync_NoOrganizationOrProviderFound_LogsErrorAndReturns()
{
// Arrange
var customerId = "cus_test";
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Organization?)null);
_providerRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Provider?)null);
// Act
await _handler.HandleAsync(_mockEvent);
@@ -96,9 +123,9 @@ public class SetupIntentSucceededHandlerTests
public async Task HandleAsync_ValidOrganization_AttachesPaymentMethodAndSendsNotification()
{
// Arrange
var organizationId = Guid.NewGuid();
var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = "cus_test" };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var organization = new Organization { Id = Guid.NewGuid(), Name = "Test Org", GatewayCustomerId = customerId };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -106,10 +133,7 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(organization);
// Act
@@ -122,15 +146,18 @@ public class SetupIntentSucceededHandlerTests
await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(organization);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
// Provider should not be queried when organization is found
await _providerRepository.DidNotReceiveWithAnyArgs().GetByGatewayCustomerIdAsync(Arg.Any<string>());
}
[Fact]
public async Task HandleAsync_ValidProvider_AttachesPaymentMethodAndSendsNotification()
{
// Arrange
var providerId = Guid.NewGuid();
var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = "cus_test" };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var provider = new Provider { Id = Guid.NewGuid(), Name = "Test Provider", GatewayCustomerId = customerId };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -138,13 +165,10 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Organization?)null);
_providerRepository.GetByIdAsync(providerId)
_providerRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(provider);
// Act
@@ -163,9 +187,9 @@ public class SetupIntentSucceededHandlerTests
public async Task HandleAsync_OrganizationWithoutGatewayCustomerId_DoesNotAttachPaymentMethod()
{
// Arrange
var organizationId = Guid.NewGuid();
var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var organization = new Organization { Id = Guid.NewGuid(), Name = "Test Org", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -173,10 +197,7 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(organization);
// Act
@@ -193,9 +214,9 @@ public class SetupIntentSucceededHandlerTests
public async Task HandleAsync_ProviderWithoutGatewayCustomerId_DoesNotAttachPaymentMethod()
{
// Arrange
var providerId = Guid.NewGuid();
var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent();
var customerId = "cus_test";
var provider = new Provider { Id = Guid.NewGuid(), Name = "Test Provider", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent(customerId: customerId);
_stripeEventService.GetSetupIntent(
_mockEvent,
@@ -203,13 +224,10 @@ public class SetupIntentSucceededHandlerTests
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
_organizationRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns((Organization?)null);
_providerRepository.GetByIdAsync(providerId)
_providerRepository.GetByGatewayCustomerIdAsync(customerId)
.Returns(provider);
// Act
@@ -222,7 +240,7 @@ public class SetupIntentSucceededHandlerTests
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
}
private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true)
private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true, string? customerId = "cus_default")
{
var paymentMethod = new PaymentMethod
{
@@ -234,6 +252,7 @@ public class SetupIntentSucceededHandlerTests
var setupIntent = new SetupIntent
{
Id = "seti_test",
CustomerId = customerId,
PaymentMethod = paymentMethod
};

View File

@@ -1,10 +1,6 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
@@ -13,9 +9,6 @@ namespace Bit.Billing.Test.Services;
public class StripeEventServiceTests
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderRepository _providerRepository;
private readonly ISetupIntentCache _setupIntentCache;
private readonly IStripeFacade _stripeFacade;
private readonly StripeEventService _stripeEventService;
@@ -25,16 +18,9 @@ public class StripeEventServiceTests
var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" };
globalSettings.BaseServiceUri = baseServiceUriSettings;
_organizationRepository = Substitute.For<IOrganizationRepository>();
_providerRepository = Substitute.For<IProviderRepository>();
_setupIntentCache = Substitute.For<ISetupIntentCache>();
_stripeFacade = Substitute.For<IStripeFacade>();
_stripeEventService = new StripeEventService(
globalSettings,
Substitute.For<ILogger<StripeEventService>>(),
_organizationRepository,
_providerRepository,
_setupIntentCache,
_stripeFacade);
}
@@ -658,29 +644,17 @@ public class StripeEventServiceTests
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationCustomer_Success()
public async Task ValidateCloudRegion_SetupIntentSucceeded_WithCustomer_Success()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var organizationId = Guid.NewGuid();
var organizationCustomerId = "cus_org_test";
var mockOrganization = new Core.AdminConsole.Entities.Organization
{
Id = organizationId,
GatewayCustomerId = organizationCustomerId
};
var customer = CreateMockCustomer();
var mockSetupIntent = new SetupIntent { Id = "seti_test", Customer = customer };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(mockOrganization);
_stripeFacade.GetCustomer(organizationCustomerId)
.Returns(customer);
_stripeFacade.GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>())
.Returns(mockSetupIntent);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -688,60 +662,22 @@ public class StripeEventServiceTests
// Assert
Assert.True(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(organizationId);
await _stripeFacade.Received(1).GetCustomer(organizationCustomerId);
await _stripeFacade.Received(1).GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderCustomer_Success()
public async Task ValidateCloudRegion_SetupIntentSucceeded_NoCustomer_ReturnsFalse()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var providerId = Guid.NewGuid();
var providerCustomerId = "cus_provider_test";
var mockProvider = new Core.AdminConsole.Entities.Provider.Provider
{
Id = providerId,
GatewayCustomerId = providerCustomerId
};
var customer = CreateMockCustomer();
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
.Returns((Core.AdminConsole.Entities.Organization?)null);
_providerRepository.GetByIdAsync(providerId)
.Returns(mockProvider);
_stripeFacade.GetCustomer(providerCustomerId)
.Returns(customer);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
Assert.True(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(providerId);
await _providerRepository.Received(1).GetByIdAsync(providerId);
await _stripeFacade.Received(1).GetCustomer(providerCustomerId);
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_NoSubscriberIdInCache_ReturnsFalse()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var mockSetupIntent = new SetupIntent { Id = "seti_test", Customer = null };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns((Guid?)null);
_stripeFacade.GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>())
.Returns(mockSetupIntent);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -749,91 +685,9 @@ public class StripeEventServiceTests
// Assert
Assert.False(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await _providerRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any<string>());
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationWithoutGatewayCustomerId_ChecksProvider()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var subscriberId = Guid.NewGuid();
var providerCustomerId = "cus_provider_test";
var mockOrganizationWithoutCustomerId = new Core.AdminConsole.Entities.Organization
{
Id = subscriberId,
GatewayCustomerId = null
};
var mockProvider = new Core.AdminConsole.Entities.Provider.Provider
{
Id = subscriberId,
GatewayCustomerId = providerCustomerId
};
var customer = CreateMockCustomer();
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(subscriberId);
_organizationRepository.GetByIdAsync(subscriberId)
.Returns(mockOrganizationWithoutCustomerId);
_providerRepository.GetByIdAsync(subscriberId)
.Returns(mockProvider);
_stripeFacade.GetCustomer(providerCustomerId)
.Returns(customer);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
Assert.True(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(subscriberId);
await _providerRepository.Received(1).GetByIdAsync(subscriberId);
await _stripeFacade.Received(1).GetCustomer(providerCustomerId);
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderWithoutGatewayCustomerId_ReturnsFalse()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
var subscriberId = Guid.NewGuid();
var mockProviderWithoutCustomerId = new Core.AdminConsole.Entities.Provider.Provider
{
Id = subscriberId,
GatewayCustomerId = null
};
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(subscriberId);
_organizationRepository.GetByIdAsync(subscriberId)
.Returns((Core.AdminConsole.Entities.Organization?)null);
_providerRepository.GetByIdAsync(subscriberId)
.Returns(mockProviderWithoutCustomerId);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
Assert.False(cloudRegionValid);
await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id);
await _organizationRepository.Received(1).GetByIdAsync(subscriberId);
await _providerRepository.Received(1).GetByIdAsync(subscriberId);
await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any<string>());
await _stripeFacade.Received(1).GetSetupIntent(
mockSetupIntent.Id,
Arg.Any<SetupIntentGetOptions>());
}
#endregion

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models;
@@ -25,7 +24,6 @@ public class UpdatePaymentMethodCommandTests
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly UpdatePaymentMethodCommand _command;
@@ -37,7 +35,6 @@ public class UpdatePaymentMethodCommandTests
_braintreeService,
_globalSettings,
Substitute.For<ILogger<UpdatePaymentMethodCommand>>(),
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
@@ -102,7 +99,8 @@ public class UpdatePaymentMethodCommandTests
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).UpdateSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == customer.Id));
}
[Fact]
@@ -166,7 +164,8 @@ public class UpdatePaymentMethodCommandTests
await _subscriberService.Received(1).CreateStripeCustomer(organization);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).UpdateSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == customer.Id));
}
[Fact]
@@ -233,7 +232,8 @@ public class UpdatePaymentMethodCommandTests
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).UpdateSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentUpdateOptions>(options => options.Customer == customer.Id));
await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>
options.Metadata[MetadataKeys.BraintreeCustomerId] == string.Empty &&
options.Metadata[MetadataKeys.RetiredBraintreeCustomerId] == "braintree_customer_id"));

View File

@@ -1,5 +1,4 @@
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;
@@ -20,7 +19,6 @@ using static StripeConstants;
public class GetPaymentMethodQueryTests
{
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly GetPaymentMethodQuery _query;
@@ -29,7 +27,6 @@ public class GetPaymentMethodQueryTests
{
_query = new GetPaymentMethodQuery(
_braintreeService,
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
@@ -181,6 +178,7 @@ public class GetPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
@@ -189,11 +187,12 @@ public class GetPaymentMethodQueryTests
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method"))).Returns(
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(
[
new SetupIntent
{
PaymentMethod = new PaymentMethod
@@ -209,7 +208,8 @@ public class GetPaymentMethodQueryTests
}
},
Status = "requires_action"
});
}
]);
var maskedPaymentMethod = await _query.Run(organization);

View File

@@ -1,5 +1,4 @@
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;
@@ -15,7 +14,6 @@ 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;
@@ -23,7 +21,6 @@ public class HasPaymentMethodQueryTests
public HasPaymentMethodQueryTests()
{
_query = new HasPaymentMethodQuery(
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
@@ -37,45 +34,12 @@ public class HasPaymentMethodQueryTests
};
_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
.GetSetupIntentAsync("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()
{
@@ -86,6 +50,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
@@ -107,6 +72,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
@@ -131,6 +97,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
DefaultSourceId = "card_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
@@ -153,28 +120,32 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(
[
new SetupIntent
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
}
});
]);
var hasPaymentMethod = await _query.Run(organization);
@@ -191,6 +162,7 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>
{
@@ -206,7 +178,7 @@ public class HasPaymentMethodQueryTests
}
[Fact]
public async Task Run_NoSetupIntentId_ReturnsFalse()
public async Task Run_NoSetupIntents_ReturnsFalse()
{
var organization = new Organization
{
@@ -215,12 +187,18 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
_stripeAdapter
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(new List<SetupIntent>());
var hasPaymentMethod = await _query.Run(organization);
@@ -237,24 +215,28 @@ public class HasPaymentMethodQueryTests
var customer = new Customer
{
Id = "cus_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.GetSetupIntentAsync("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
PaymentMethod = new PaymentMethod
.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.HasExpansions("data.payment_method")))
.Returns(
[
new SetupIntent
{
Type = "card"
},
Status = "succeeded"
});
PaymentMethod = new PaymentMethod
{
Type = "card"
},
Status = "succeeded"
}
]);
var hasPaymentMethod = await _query.Run(organization);

View File

@@ -1,5 +1,4 @@
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
@@ -32,7 +31,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly IUserService _userService = Substitute.For<IUserService>();
@@ -63,7 +61,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
_braintreeGateway,
_braintreeService,
_globalSettings,
_setupIntentCache,
_stripeAdapter,
_subscriberService,
_userService,
@@ -110,63 +107,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.Equal("Additional storage must be greater than 0.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_ValidPaymentMethodTypes_BankAccount_Success(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null; // Ensure no existing customer ID
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.BankAccount;
paymentMethod.Token = "bank_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
var mockSetupIntent = Substitute.For<SetupIntent>();
mockSetupIntent.Id = "seti_123";
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateInvoiceAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
_stripeAdapter.ListSetupIntentsAsync(Arg.Any<SetupIntentListOptions>()).Returns(Task.FromResult(new List<SetupIntent> { mockSetupIntent }));
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.Received(1).SaveUserAsync(user);
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
}
[Theory, BitAutoData]
public async Task Run_ValidPaymentMethodTypes_Card_Success(
User user,
@@ -625,60 +565,6 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
}
[Theory, BitAutoData]
public async Task Run_BankAccountWithNoSetupIntentFound_ReturnsUnhandled(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.BankAccount;
paymentMethod.Token = "bank_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "incomplete";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateInvoiceAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.ListSetupIntentsAsync(Arg.Any<SetupIntentListOptions>())
.Returns(Task.FromResult(new List<SetupIntent>())); // Empty list - no setup intent found
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT3);
var unhandled = result.AsT3;
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
}
[Theory, BitAutoData]
public async Task Run_AccountCredit_WithExistingCustomer_Success(
User user,

View File

@@ -1,13 +1,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Enums;
using Bit.Core.Settings;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Braintree;
@@ -20,7 +18,6 @@ using Xunit;
using static Bit.Core.Test.Billing.Utilities;
using Address = Stripe.Address;
using Customer = Stripe.Customer;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
using PaymentMethod = Stripe.PaymentMethod;
using Subscription = Stripe.Subscription;
@@ -519,10 +516,11 @@ public class SubscriberServiceTests
}
};
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id);
sutProvider.GetDependency<IStripeAdapter>().GetSetupIntentAsync(setupIntent.Id,
Arg.Is<SetupIntentGetOptions>(options => options.Expand.Contains("payment_method"))).Returns(setupIntent);
sutProvider.GetDependency<IStripeAdapter>().ListSetupIntentsAsync(
Arg.Is<SetupIntentListOptions>(options =>
options.Customer == customer.Id &&
options.Expand.Contains("data.payment_method")))
.Returns([setupIntent]);
var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider);
@@ -1032,423 +1030,6 @@ public class SubscriberServiceTests
}
#endregion
#region UpdatePaymentMethod
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_NullSubscriber_ThrowsArgumentNullException(
SutProvider<SubscriberService> sutProvider)
=> await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdatePaymentSource(null, null));
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_NullTokenizedPaymentMethod_ThrowsArgumentNullException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
=> await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdatePaymentSource(provider, null));
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_NoToken_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer());
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.Card, null)));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_UnsupportedPaymentMethod_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer());
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.BitPay, "TOKEN")));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_BankAccount_IncorrectNumberOfSetupIntentsForToken_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer());
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options => options.PaymentMethod == "TOKEN"))
.Returns([new SetupIntent(), new SetupIntent()]);
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.BankAccount, "TOKEN")));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_BankAccount_Succeeds(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "braintree_customer_id"
}
});
var matchingSetupIntent = new SetupIntent { Id = "setup_intent_1" };
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options => options.PaymentMethod == "TOKEN"))
.Returns([matchingSetupIntent]);
stripeAdapter.ListCustomerPaymentMethodsAsync(provider.GatewayCustomerId).Returns([
new PaymentMethod { Id = "payment_method_1" }
]);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.BankAccount, "TOKEN"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_1");
await stripeAdapter.DidNotReceive().CancelSetupIntentAsync(Arg.Any<string>(),
Arg.Any<SetupIntentCancelOptions>());
await stripeAdapter.Received(1).DetachPaymentMethodAsync("payment_method_1");
await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Card_Succeeds(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))
)
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "braintree_customer_id"
}
});
stripeAdapter.ListCustomerPaymentMethodsAsync(provider.GatewayCustomerId).Returns([
new PaymentMethod { Id = "payment_method_1" }
]);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.Card, "TOKEN"));
await stripeAdapter.DidNotReceive().CancelSetupIntentAsync(Arg.Any<string>(),
Arg.Any<SetupIntentCancelOptions>());
await stripeAdapter.Received(1).DetachPaymentMethodAsync("payment_method_1");
await stripeAdapter.Received(1).AttachPaymentMethodAsync("TOKEN", Arg.Is<PaymentMethodAttachOptions>(
options => options.Customer == provider.GatewayCustomerId));
await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options =>
options.InvoiceSettings.DefaultPaymentMethod == "TOKEN" &&
options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null));
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_NullCustomer_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
customerGateway.FindAsync(braintreeCustomerId).ReturnsNull();
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await paymentMethodGateway.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<PaymentMethodRequest>());
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_CreatePaymentMethodFails_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var customer = Substitute.For<Braintree.Customer>();
customer.Id.Returns(braintreeCustomerId);
customerGateway.FindAsync(braintreeCustomerId).Returns(customer);
var createPaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
createPaymentMethodResult.IsSuccess().Returns(false);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(
options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN"))
.Returns(createPaymentMethodResult);
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any<string>(), Arg.Any<CustomerRequest>());
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_UpdateCustomerFails_DeletePaymentMethod_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var customer = Substitute.For<Braintree.Customer>();
customer.Id.Returns(braintreeCustomerId);
customerGateway.FindAsync(braintreeCustomerId).Returns(customer);
var createPaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
var createdPaymentMethod = Substitute.For<Braintree.PaymentMethod>();
createdPaymentMethod.Token.Returns("TOKEN");
createPaymentMethodResult.IsSuccess().Returns(true);
createPaymentMethodResult.Target.Returns(createdPaymentMethod);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(
options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN"))
.Returns(createPaymentMethodResult);
var updateCustomerResult = Substitute.For<Result<Braintree.Customer>>();
updateCustomerResult.IsSuccess().Returns(false);
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(options =>
options.DefaultPaymentMethodToken == createPaymentMethodResult.Target.Token))
.Returns(updateCustomerResult);
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await paymentMethodGateway.Received(1).DeleteAsync(createPaymentMethodResult.Target.Token);
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_Success(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId,
Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId
}
});
var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var customer = Substitute.For<Braintree.Customer>();
var existingPaymentMethod = Substitute.For<Braintree.PaymentMethod>();
existingPaymentMethod.Token.Returns("OLD_TOKEN");
existingPaymentMethod.IsDefault.Returns(true);
customer.PaymentMethods.Returns([existingPaymentMethod]);
customer.Id.Returns(braintreeCustomerId);
customerGateway.FindAsync(braintreeCustomerId).Returns(customer);
var createPaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
var updatedPaymentMethod = Substitute.For<Braintree.PaymentMethod>();
updatedPaymentMethod.Token.Returns("TOKEN");
createPaymentMethodResult.IsSuccess().Returns(true);
createPaymentMethodResult.Target.Returns(updatedPaymentMethod);
paymentMethodGateway.CreateAsync(Arg.Is<PaymentMethodRequest>(
options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN"))
.Returns(createPaymentMethodResult);
var updateCustomerResult = Substitute.For<Result<Braintree.Customer>>();
updateCustomerResult.IsSuccess().Returns(true);
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Is<CustomerRequest>(options =>
options.DefaultPaymentMethodToken == createPaymentMethodResult.Target.Token))
.Returns(updateCustomerResult);
var deletePaymentMethodResult = Substitute.For<Result<Braintree.PaymentMethod>>();
deletePaymentMethodResult.IsSuccess().Returns(true);
paymentMethodGateway.DeleteAsync(existingPaymentMethod.Token).Returns(deletePaymentMethodResult);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN"));
await paymentMethodGateway.Received(1).DeleteAsync(existingPaymentMethod.Token);
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_CreateCustomer_CustomerUpdateFails_ThrowsBillingException(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(provider.GatewayCustomerId)
.Returns(new Customer
{
Id = provider.GatewayCustomerId
});
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings())
{
CloudRegion = "US"
});
var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var createCustomerResult = Substitute.For<Result<Braintree.Customer>>();
createCustomerResult.IsSuccess().Returns(false);
customerGateway.CreateAsync(Arg.Is<CustomerRequest>(
options =>
options.Id == braintreeCustomerId &&
options.CustomFields[provider.BraintreeIdField()] == provider.Id.ToString() &&
options.CustomFields[provider.BraintreeCloudRegionField()] == "US" &&
options.Email == provider.BillingEmailAddress() &&
options.PaymentMethodNonce == "TOKEN"))
.Returns(createCustomerResult);
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")));
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdatePaymentMethod_Braintree_CreateCustomer_Succeeds(
Provider provider,
SutProvider<SubscriberService> sutProvider)
{
const string braintreeCustomerId = "braintree_customer_id";
sutProvider.GetDependency<IStripeAdapter>().GetCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")))
.Returns(new Customer
{
Id = provider.GatewayCustomerId
});
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new GlobalSettings.BaseServiceUriSettings(new GlobalSettings())
{
CloudRegion = "US"
});
var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency<IBraintreeGateway>());
var createCustomerResult = Substitute.For<Result<Braintree.Customer>>();
var createdCustomer = Substitute.For<Braintree.Customer>();
createdCustomer.Id.Returns(braintreeCustomerId);
createCustomerResult.IsSuccess().Returns(true);
createCustomerResult.Target.Returns(createdCustomer);
customerGateway.CreateAsync(Arg.Is<CustomerRequest>(
options =>
options.CustomFields[provider.BraintreeIdField()] == provider.Id.ToString() &&
options.CustomFields[provider.BraintreeCloudRegionField()] == "US" &&
options.Email == provider.BillingEmailAddress() &&
options.PaymentMethodNonce == "TOKEN"))
.Returns(createCustomerResult);
await sutProvider.Sut.UpdatePaymentSource(provider,
new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN"));
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(
options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == braintreeCustomerId));
}
#endregion
#region UpdateTaxInformation
[Theory, BitAutoData]

View File

@@ -0,0 +1,141 @@
-- Add indexes for Organization
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Organization_GatewayCustomerId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_Organization_GatewayCustomerId]
ON [dbo].[Organization]([GatewayCustomerId])
WHERE [GatewayCustomerId] IS NOT NULL;
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Organization_GatewaySubscriptionId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_Organization_GatewaySubscriptionId]
ON [dbo].[Organization]([GatewaySubscriptionId])
WHERE [GatewaySubscriptionId] IS NOT NULL;
END
GO
-- Add indexes for Provider
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Provider_GatewayCustomerId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_Provider_GatewayCustomerId]
ON [dbo].[Provider]([GatewayCustomerId])
WHERE [GatewayCustomerId] IS NOT NULL;
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Provider_GatewaySubscriptionId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_Provider_GatewaySubscriptionId]
ON [dbo].[Provider]([GatewaySubscriptionId])
WHERE [GatewaySubscriptionId] IS NOT NULL;
END
GO
-- Add indexes for User
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_User_GatewayCustomerId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_User_GatewayCustomerId]
ON [dbo].[User]([GatewayCustomerId])
WHERE [GatewayCustomerId] IS NOT NULL;
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_User_GatewaySubscriptionId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_User_GatewaySubscriptionId]
ON [dbo].[User]([GatewaySubscriptionId])
WHERE [GatewaySubscriptionId] IS NOT NULL;
END
GO
-- Create stored procedures
CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadByGatewayCustomerId]
@GatewayCustomerId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[OrganizationView]
WHERE
[GatewayCustomerId] = @GatewayCustomerId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadByGatewaySubscriptionId]
@GatewaySubscriptionId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[OrganizationView]
WHERE
[GatewaySubscriptionId] = @GatewaySubscriptionId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Provider_ReadByGatewayCustomerId]
@GatewayCustomerId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ProviderView]
WHERE
[GatewayCustomerId] = @GatewayCustomerId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Provider_ReadByGatewaySubscriptionId]
@GatewaySubscriptionId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[ProviderView]
WHERE
[GatewaySubscriptionId] = @GatewaySubscriptionId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[User_ReadByGatewayCustomerId]
@GatewayCustomerId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[UserView]
WHERE
[GatewayCustomerId] = @GatewayCustomerId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[User_ReadByGatewaySubscriptionId]
@GatewaySubscriptionId VARCHAR(50)
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[UserView]
WHERE
[GatewaySubscriptionId] = @GatewaySubscriptionId
END
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class AddGatewayIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "GatewaySubscriptionId",
table: "Provider",
type: "varchar(255)",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "GatewayCustomerId",
table: "Provider",
type: "varchar(255)",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_User_GatewayCustomerId",
table: "User",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_User_GatewaySubscriptionId",
table: "User",
column: "GatewaySubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_Provider_GatewayCustomerId",
table: "Provider",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Provider_GatewaySubscriptionId",
table: "Provider",
column: "GatewaySubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_Organization_GatewayCustomerId",
table: "Organization",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Organization_GatewaySubscriptionId",
table: "Organization",
column: "GatewaySubscriptionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_User_GatewayCustomerId",
table: "User");
migrationBuilder.DropIndex(
name: "IX_User_GatewaySubscriptionId",
table: "User");
migrationBuilder.DropIndex(
name: "IX_Provider_GatewayCustomerId",
table: "Provider");
migrationBuilder.DropIndex(
name: "IX_Provider_GatewaySubscriptionId",
table: "Provider");
migrationBuilder.DropIndex(
name: "IX_Organization_GatewayCustomerId",
table: "Organization");
migrationBuilder.DropIndex(
name: "IX_Organization_GatewaySubscriptionId",
table: "Organization");
migrationBuilder.AlterColumn<string>(
name: "GatewaySubscriptionId",
table: "Provider",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "GatewayCustomerId",
table: "Provider",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class UpdateProviderGatewayColumnLengths : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "GatewaySubscriptionId",
table: "Provider",
type: "varchar(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "GatewayCustomerId",
table: "Provider",
type: "varchar(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(255)",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "GatewaySubscriptionId",
table: "Provider",
type: "varchar(255)",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(50)",
oldMaxLength: 50,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "GatewayCustomerId",
table: "Provider",
type: "varchar(255)",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(50)",
oldMaxLength: 50,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}

View File

@@ -276,6 +276,10 @@ namespace Bit.MySqlMigrations.Migrations
b.HasKey("Id");
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.HasIndex("Id", "Enabled")
.HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" });
@@ -359,10 +363,12 @@ namespace Bit.MySqlMigrations.Migrations
.HasColumnType("tinyint unsigned");
b.Property<string>("GatewayCustomerId")
.HasColumnType("longtext");
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("GatewaySubscriptionId")
.HasColumnType("longtext");
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Name")
.HasColumnType("longtext");
@@ -381,6 +387,10 @@ namespace Bit.MySqlMigrations.Migrations
b.HasKey("Id");
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.ToTable("Provider", (string)null);
});
@@ -975,8 +985,7 @@ namespace Bit.MySqlMigrations.Migrations
b.HasIndex("StartDate", "EndDate")
.HasDatabaseName("IX_SubscriptionDiscount_DateRange")
.HasAnnotation("SqlServer:Clustered", false)
.HasAnnotation("SqlServer:Include", new[] { "StripeProductIds", "AudienceType" });
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("SubscriptionDiscount", (string)null);
});
@@ -2052,6 +2061,10 @@ namespace Bit.MySqlMigrations.Migrations
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate")
.HasAnnotation("SqlServer:Clustered", false);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class AddGatewayIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_User_GatewayCustomerId",
table: "User",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_User_GatewaySubscriptionId",
table: "User",
column: "GatewaySubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_Provider_GatewayCustomerId",
table: "Provider",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Provider_GatewaySubscriptionId",
table: "Provider",
column: "GatewaySubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_Organization_GatewayCustomerId",
table: "Organization",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Organization_GatewaySubscriptionId",
table: "Organization",
column: "GatewaySubscriptionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_User_GatewayCustomerId",
table: "User");
migrationBuilder.DropIndex(
name: "IX_User_GatewaySubscriptionId",
table: "User");
migrationBuilder.DropIndex(
name: "IX_Provider_GatewayCustomerId",
table: "Provider");
migrationBuilder.DropIndex(
name: "IX_Provider_GatewaySubscriptionId",
table: "Provider");
migrationBuilder.DropIndex(
name: "IX_Organization_GatewayCustomerId",
table: "Organization");
migrationBuilder.DropIndex(
name: "IX_Organization_GatewaySubscriptionId",
table: "Organization");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class UpdateProviderGatewayColumnLengths : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "GatewaySubscriptionId",
table: "Provider",
type: "character varying(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "GatewayCustomerId",
table: "Provider",
type: "character varying(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "GatewaySubscriptionId",
table: "Provider",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "GatewayCustomerId",
table: "Provider",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50,
oldNullable: true);
}
}

View File

@@ -278,6 +278,10 @@ namespace Bit.PostgresMigrations.Migrations
b.HasKey("Id");
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.HasIndex("Id", "Enabled");
NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" });
@@ -362,10 +366,12 @@ namespace Bit.PostgresMigrations.Migrations
.HasColumnType("smallint");
b.Property<string>("GatewayCustomerId")
.HasColumnType("text");
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("GatewaySubscriptionId")
.HasColumnType("text");
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Name")
.HasColumnType("text");
@@ -384,6 +390,10 @@ namespace Bit.PostgresMigrations.Migrations
b.HasKey("Id");
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.ToTable("Provider", (string)null);
});
@@ -980,8 +990,7 @@ namespace Bit.PostgresMigrations.Migrations
b.HasIndex("StartDate", "EndDate")
.HasDatabaseName("IX_SubscriptionDiscount_DateRange")
.HasAnnotation("SqlServer:Clustered", false)
.HasAnnotation("SqlServer:Include", new[] { "StripeProductIds", "AudienceType" });
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("SubscriptionDiscount", (string)null);
});
@@ -2058,6 +2067,10 @@ namespace Bit.PostgresMigrations.Migrations
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate")
.HasAnnotation("SqlServer:Clustered", false);

View File

@@ -13,12 +13,36 @@ internal static class Passwords
/// </summary>
internal static readonly string[] VeryWeak =
[
"password", "123456", "qwerty", "abc123", "letmein",
"admin", "welcome", "monkey", "dragon", "master",
"111111", "baseball", "iloveyou", "trustno1", "sunshine",
"princess", "football", "shadow", "superman", "michael",
"password1", "123456789", "12345678", "1234567", "12345",
"qwerty123", "1q2w3e4r", "123123", "000000", "654321"
"password",
"123456",
"qwerty",
"abc123",
"letmein",
"admin",
"welcome",
"monkey",
"dragon",
"master",
"111111",
"baseball",
"iloveyou",
"trustno1",
"sunshine",
"princess",
"football",
"shadow",
"superman",
"michael",
"password1",
"123456789",
"12345678",
"1234567",
"12345",
"qwerty123",
"1q2w3e4r",
"123123",
"000000",
"654321"
];
/// <summary>
@@ -26,12 +50,36 @@ internal static class Passwords
/// </summary>
internal static readonly string[] Weak =
[
"Password1", "Qwerty123", "Welcome1", "Admin123", "Letmein1",
"Dragon123", "Master123", "Shadow123", "Michael1", "Jennifer1",
"abc123!", "pass123!", "test1234", "hello123", "love1234",
"money123", "secret1", "access1", "login123", "super123",
"changeme", "temp1234", "guest123", "user1234", "pass1234",
"default1", "sample12", "demo1234", "trial123", "secure1"
"Password1",
"Qwerty123",
"Welcome1",
"Admin123",
"Letmein1",
"Dragon123",
"Master123",
"Shadow123",
"Michael1",
"Jennifer1",
"abc123!",
"pass123!",
"test1234",
"hello123",
"love1234",
"money123",
"secret1",
"access1",
"login123",
"super123",
"changeme",
"temp1234",
"guest123",
"user1234",
"pass1234",
"default1",
"sample12",
"demo1234",
"trial123",
"secure1"
];
/// <summary>
@@ -39,12 +87,36 @@ internal static class Passwords
/// </summary>
internal static readonly string[] Fair =
[
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!",
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login2024!",
"Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!", "David2024!",
"Password123!", "Security2024@", "Admin2024!", "User2024#", "Guest123!",
"Football123!", "Baseball2024@", "Soccer456#", "Hockey789!", "Tennis2024!",
"NewYork2024!", "Chicago123@", "Boston2024#", "Seattle789!", "Denver2024$"
"Summer2024!",
"Winter2023#",
"Spring2024@",
"Autumn2023$",
"January2024!",
"Welcome123!",
"Company2024#",
"Secure123!",
"Access2024@",
"Login2024!",
"Michael123!",
"Jennifer2024@",
"Robert456#",
"Sarah789!",
"David2024!",
"Password123!",
"Security2024@",
"Admin2024!",
"User2024#",
"Guest123!",
"Football123!",
"Baseball2024@",
"Soccer456#",
"Hockey789!",
"Tennis2024!",
"NewYork2024!",
"Chicago123@",
"Boston2024#",
"Seattle789!",
"Denver2024$"
];
/// <summary>
@@ -52,14 +124,30 @@ internal static class Passwords
/// </summary>
internal static readonly string[] Strong =
[
"k#9Lm$vQ2@xR7nP!", "Yx8&mK3$pL5#wQ9@", "Nv4%jH7!bT2@sF6#",
"Rm9#cX5$gW1@zK8!", "Qp3@hY6#nL9$tB2!", "Wz7!mF4@kS8#xC1$",
"Jd2#pR9!vN5@bG7$", "Ht6@wL3#yK8!mQ4$", "Bf8$cM2@zT5#rX9!",
"Lg1!nV7@sH4#pY6$", "Xk5#tW8@jR2$mN9!", "Cv3@yB6#pF1$qL4!",
"correct-horse-battery", "purple-monkey-dishwasher", "quantum-bicycle-elephant",
"velvet-thunder-crystal", "neon-wizard-cosmic", "amber-phoenix-digital",
"Brave.Tiger.Runs.42", "Blue.Ocean.Deep.17", "Swift.Eagle.Soars.93",
"maple#stream#winter", "ember@cloud@silent", "frost$dawn$valley"
"k#9Lm$vQ2@xR7nP!",
"Yx8&mK3$pL5#wQ9@",
"Nv4%jH7!bT2@sF6#",
"Rm9#cX5$gW1@zK8!",
"Qp3@hY6#nL9$tB2!",
"Wz7!mF4@kS8#xC1$",
"Jd2#pR9!vN5@bG7$",
"Ht6@wL3#yK8!mQ4$",
"Bf8$cM2@zT5#rX9!",
"Lg1!nV7@sH4#pY6$",
"Xk5#tW8@jR2$mN9!",
"Cv3@yB6#pF1$qL4!",
"correct-horse-battery",
"purple-monkey-dishwasher",
"quantum-bicycle-elephant",
"velvet-thunder-crystal",
"neon-wizard-cosmic",
"amber-phoenix-digital",
"Brave.Tiger.Runs.42",
"Blue.Ocean.Deep.17",
"Swift.Eagle.Soars.93",
"maple#stream#winter",
"ember@cloud@silent",
"frost$dawn$valley"
];
/// <summary>
@@ -67,14 +155,30 @@ internal static class Passwords
/// </summary>
internal static readonly string[] VeryStrong =
[
"Kx9#mL4$pQ7@wR2!vN5hT8", "Yz3@hT8#bF1$cS6!nM9wK4", "Wv5!rK2@jG9#tX4$mL7nB3",
"Qn7$sB3@yH6#pC1!zF8kW2", "Tm2@xD5#kW9$vL4!rJ7gN1", "Pf4!nC8@bR3#yL6$hS9mV2",
"correct-horse-battery-staple", "purple-monkey-dishwasher-lamp", "quantum-bicycle-elephant-storm",
"velvet-thunder-crystal-forge", "neon-wizard-cosmic-river", "amber-phoenix-digital-maze",
"silver-falcon-ancient-code", "lunar-garden-frozen-spark", "echo-prism-wandering-light",
"Brave.Tiger.Runs.Fast.42!", "Blue.Ocean.Deep.Wave.17@", "Swift.Eagle.Soars.High.93#",
"maple#stream#winter#glow#dawn", "ember@cloud@silent@peak@mist", "frost$dawn$valley$mist$glow",
"7hK$mN2@pL9#xR4!wQ8vB5&jF", "3yT@nC7#bS1$kW6!mH9rL2%xD", "9pF!vK4@jR8#tN3$yB7mL1&wS"
"Kx9#mL4$pQ7@wR2!vN5hT8",
"Yz3@hT8#bF1$cS6!nM9wK4",
"Wv5!rK2@jG9#tX4$mL7nB3",
"Qn7$sB3@yH6#pC1!zF8kW2",
"Tm2@xD5#kW9$vL4!rJ7gN1",
"Pf4!nC8@bR3#yL6$hS9mV2",
"correct-horse-battery-staple",
"purple-monkey-dishwasher-lamp",
"quantum-bicycle-elephant-storm",
"velvet-thunder-crystal-forge",
"neon-wizard-cosmic-river",
"amber-phoenix-digital-maze",
"silver-falcon-ancient-code",
"lunar-garden-frozen-spark",
"echo-prism-wandering-light",
"Brave.Tiger.Runs.Fast.42!",
"Blue.Ocean.Deep.Wave.17@",
"Swift.Eagle.Soars.High.93#",
"maple#stream#winter#glow#dawn",
"ember@cloud@silent@peak@mist",
"frost$dawn$valley$mist$glow",
"7hK$mN2@pL9#xR4!wQ8vB5&jF",
"3yT@nC7#bS1$kW6!mH9rL2%xD",
"9pF!vK4@jR8#tN3$yB7mL1&wS"
];
/// <summary>All passwords combined for mixed/random selection.</summary>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class AddGatewayIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_User_GatewayCustomerId",
table: "User",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_User_GatewaySubscriptionId",
table: "User",
column: "GatewaySubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_Provider_GatewayCustomerId",
table: "Provider",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Provider_GatewaySubscriptionId",
table: "Provider",
column: "GatewaySubscriptionId");
migrationBuilder.CreateIndex(
name: "IX_Organization_GatewayCustomerId",
table: "Organization",
column: "GatewayCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Organization_GatewaySubscriptionId",
table: "Organization",
column: "GatewaySubscriptionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_User_GatewayCustomerId",
table: "User");
migrationBuilder.DropIndex(
name: "IX_User_GatewaySubscriptionId",
table: "User");
migrationBuilder.DropIndex(
name: "IX_Provider_GatewayCustomerId",
table: "Provider");
migrationBuilder.DropIndex(
name: "IX_Provider_GatewaySubscriptionId",
table: "Provider");
migrationBuilder.DropIndex(
name: "IX_Organization_GatewayCustomerId",
table: "Organization");
migrationBuilder.DropIndex(
name: "IX_Organization_GatewaySubscriptionId",
table: "Organization");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class UpdateProviderGatewayColumnLengths : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}

View File

@@ -271,6 +271,10 @@ namespace Bit.SqliteMigrations.Migrations
b.HasKey("Id");
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.HasIndex("Id", "Enabled")
.HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" });
@@ -354,9 +358,11 @@ namespace Bit.SqliteMigrations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("GatewayCustomerId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("GatewaySubscriptionId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Name")
@@ -376,6 +382,10 @@ namespace Bit.SqliteMigrations.Migrations
b.HasKey("Id");
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.ToTable("Provider", (string)null);
});
@@ -940,6 +950,7 @@ namespace Bit.SqliteMigrations.Migrations
.HasColumnType("TEXT");
b.Property<decimal?>("PercentOff")
.HasPrecision(5, 2)
.HasColumnType("TEXT");
b.Property<DateTime>("RevisionDate")
@@ -963,8 +974,7 @@ namespace Bit.SqliteMigrations.Migrations
b.HasIndex("StartDate", "EndDate")
.HasDatabaseName("IX_SubscriptionDiscount_DateRange")
.HasAnnotation("SqlServer:Clustered", false)
.HasAnnotation("SqlServer:Include", new[] { "StripeProductIds", "AudienceType" });
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("SubscriptionDiscount", (string)null);
});
@@ -2040,6 +2050,10 @@ namespace Bit.SqliteMigrations.Migrations
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("GatewayCustomerId");
b.HasIndex("GatewaySubscriptionId");
b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate")
.HasAnnotation("SqlServer:Clustered", false);