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:
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 **
|
||||
*******************/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
3518
util/MySqlMigrations/Migrations/20260204232320_AddGatewayIndexes.Designer.cs
generated
Normal file
3518
util/MySqlMigrations/Migrations/20260204232320_AddGatewayIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
3579
util/MySqlMigrations/Migrations/20260212191908_UpdateProviderGatewayColumnLengths.Designer.cs
generated
Normal file
3579
util/MySqlMigrations/Migrations/20260212191908_UpdateProviderGatewayColumnLengths.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
3524
util/PostgresMigrations/Migrations/20260204232313_AddGatewayIndexes.Designer.cs
generated
Normal file
3524
util/PostgresMigrations/Migrations/20260204232313_AddGatewayIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
3585
util/PostgresMigrations/Migrations/20260212191915_UpdateProviderGatewayColumnLengths.Designer.cs
generated
Normal file
3585
util/PostgresMigrations/Migrations/20260212191915_UpdateProviderGatewayColumnLengths.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
3507
util/SqliteMigrations/Migrations/20260204232306_AddGatewayIndexes.Designer.cs
generated
Normal file
3507
util/SqliteMigrations/Migrations/20260204232306_AddGatewayIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
3568
util/SqliteMigrations/Migrations/20260212191921_UpdateProviderGatewayColumnLengths.Designer.cs
generated
Normal file
3568
util/SqliteMigrations/Migrations/20260212191921_UpdateProviderGatewayColumnLengths.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user