[PM-24964] Stripe-hosted bank account verification (#6263)

* Implement bank account hosted URL verification with webhook handling notification

* Fix tests

* Run dotnet format

* Remove unused VerifyBankAccount operation

* Stephon's feedback

* Removing unused test

* TEMP: Add logging for deployment check

* Run dotnet format

* fix test

* Revert "fix test"

This reverts commit b8743ab3b57d93eb12754ac586b0bce100834f48.

* Revert "Run dotnet format"

This reverts commit 5c861b0b72131b954b244639bf3fa1b4a303515e.

* Revert "TEMP: Add logging for deployment check"

This reverts commit 0a88acd6a1571a9c3a24657c0e199a5fc18e9a50.

* Resolve GetPaymentMethodQuery order of operations
This commit is contained in:
Alex Morask 2025-09-09 12:22:42 -05:00 committed by GitHub
parent ac718351a8
commit 3dd5accb56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1136 additions and 814 deletions

View File

@ -636,10 +636,10 @@ public class ProviderBillingService(
{
case PaymentMethodType.BankAccount:
{
var setupIntentId = await setupIntentCache.Get(provider.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.Remove(provider.Id);
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
@ -689,7 +689,7 @@ public class ProviderBillingService(
});
}
var setupIntentId = await setupIntentCache.Get(provider.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions

View File

@ -1003,7 +1003,7 @@ public class ProviderBillingServiceTests
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Throws<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
@ -1013,7 +1013,7 @@ public class ProviderBillingServiceTests
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
}
[Theory, BitAutoData]
@ -1644,7 +1644,7 @@ public class ProviderBillingServiceTests
const string setupIntentId = "seti_123";
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent

View File

@ -25,8 +25,7 @@ public class OrganizationBillingVNextController(
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
{
[Authorize<ManageOrganizationBillingRequirement>]
[HttpGet("address")]
@ -96,17 +95,6 @@ public class OrganizationBillingVNextController(
return Handle(result);
}
[Authorize<ManageOrganizationBillingRequirement>]
[HttpPost("payment-method/verify-bank-account")]
[InjectOrganization]
public async Task<IResult> VerifyBankAccountAsync(
[BindNever] Organization organization,
[FromBody] VerifyBankAccountRequest request)
{
var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode);
return Handle(result);
}
[Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")]
[InjectOrganization]

View File

@ -23,8 +23,7 @@ public class ProviderBillingVNextController(
IGetProviderWarningsQuery getProviderWarningsQuery,
IProviderService providerService,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
{
[HttpGet("address")]
[InjectProvider(ProviderUserType.ProviderAdmin)]
@ -97,16 +96,6 @@ public class ProviderBillingVNextController(
return Handle(result);
}
[HttpPost("payment-method/verify-bank-account")]
[InjectProvider(ProviderUserType.ProviderAdmin)]
public async Task<IResult> VerifyBankAccountAsync(
[BindNever] Provider provider,
[FromBody] VerifyBankAccountRequest request)
{
var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode);
return Handle(result);
}
[HttpGet("warnings")]
[InjectProvider(ProviderUserType.ServiceUser)]
public async Task<IResult> GetWarningsAsync(

View File

@ -13,4 +13,5 @@ public static class HandledStripeWebhook
public const string PaymentMethodAttached = "payment_method.attached";
public const string CustomerUpdated = "customer.updated";
public const string InvoiceFinalized = "invoice.finalized";
public const string SetupIntentSucceeded = "setup_intent.succeeded";
}

View File

@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
namespace Bit.Billing.Services;
public interface IPushNotificationAdapter
{
Task NotifyBankAccountVerifiedAsync(Organization organization);
Task NotifyBankAccountVerifiedAsync(Provider provider);
Task NotifyEnabledChangedAsync(Organization organization);
}

View File

@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Stripe;
using Stripe;
namespace Bit.Billing.Services;
@ -13,12 +10,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the charge object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the charge object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh charge object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Charge"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a charge object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null charge object.</exception>
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="Customer"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@ -26,12 +21,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the customer object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the customer object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh customer object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Customer"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a customer object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null customer object.</exception>
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="Invoice"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@ -39,12 +32,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the invoice object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the invoice object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh invoice object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Invoice"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an invoice object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null invoice object.</exception>
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="PaymentMethod"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@ -52,12 +43,21 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the payment method object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the payment method object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh payment method object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="PaymentMethod"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an payment method object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null payment method object.</exception>
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="SetupIntent"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the setup intent ID extracted from the event to retrieve the most up-to-update setup intent from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the setup intent object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh setup intent object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="SetupIntent"/>.</returns>
Task<SetupIntent> GetSetupIntent(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="Subscription"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@ -65,12 +65,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the subscription object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the subscription object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh subscription object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an subscription object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null subscription object.</exception>
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server.

View File

@ -38,6 +38,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<SetupIntent> GetSetupIntent(
string setupIntentId,
SetupIntentGetOptions setupIntentGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<StripeList<Invoice>> ListInvoices(
InvoiceListOptions options = null,
RequestOptions requestOptions = null,

View File

@ -65,3 +65,5 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler;
/// Defines the contract for handling Stripe Invoice Finalized events.
/// </summary>
public interface IInvoiceFinalizedHandler : IStripeWebhookHandler;
public interface ISetupIntentSucceededHandler : IStripeWebhookHandler;

View File

@ -3,63 +3,38 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class PaymentSucceededHandler : IPaymentSucceededHandler
public class PaymentSucceededHandler(
ILogger<PaymentSucceededHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IProviderRepository providerRepository,
IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService,
IUserService userService,
IOrganizationEnableCommand organizationEnableCommand,
IPricingClient pricingClient,
IPushNotificationAdapter pushNotificationAdapter)
: IPaymentSucceededHandler
{
private readonly ILogger<PaymentSucceededHandler> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly IUserService _userService;
private readonly IStripeFacade _stripeFacade;
private readonly IProviderRepository _providerRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IPricingClient _pricingClient;
public PaymentSucceededHandler(
ILogger<PaymentSucceededHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IProviderRepository providerRepository,
IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService,
IUserService userService,
IPushNotificationService pushNotificationService,
IOrganizationEnableCommand organizationEnableCommand,
IPricingClient pricingClient)
{
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_providerRepository = providerRepository;
_organizationRepository = organizationRepository;
_stripeEventUtilityService = stripeEventUtilityService;
_userService = userService;
_pushNotificationService = pushNotificationService;
_organizationEnableCommand = organizationEnableCommand;
_pricingClient = pricingClient;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true);
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
{
return;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId);
if (subscription?.Status != StripeSubscriptionStatus.Active)
{
return;
@ -70,15 +45,15 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
await Task.Delay(5000);
}
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
var provider = await providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
_logger.LogError(
logger.LogError(
"Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
parsedEvent.Id,
providerId.Value);
@ -86,9 +61,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
return;
}
var teamsMonthly = await _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly);
var teamsMonthly = await pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly);
var enterpriseMonthly = await _pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly);
var enterpriseMonthly = await pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly);
var teamsMonthlyLineItem =
subscription.Items.Data.FirstOrDefault(item =>
@ -100,29 +75,30 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null)
{
_logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
parsedEvent.Id,
provider.Id);
}
}
else if (organizationId.HasValue)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
return;
}
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id))
{
return;
}
await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await _pushNotificationService.PushSyncOrganizationStatusAsync(organization);
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
organization = await organizationRepository.GetByIdAsync(organization.Id);
await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!);
}
else if (userId.HasValue)
{
@ -131,7 +107,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
return;
}
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
}
}
}

View File

@ -0,0 +1,71 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
namespace Bit.Billing.Services.Implementations;
public class PushNotificationAdapter(
IProviderUserRepository providerUserRepository,
IPushNotificationService pushNotificationService) : IPushNotificationAdapter
{
public Task NotifyBankAccountVerifiedAsync(Organization organization) =>
pushNotificationService.PushAsync(new PushNotification<OrganizationBankAccountVerifiedPushNotification>
{
Type = PushType.OrganizationBankAccountVerified,
Target = NotificationTarget.Organization,
TargetId = organization.Id,
Payload = new OrganizationBankAccountVerifiedPushNotification
{
OrganizationId = organization.Id
},
ExcludeCurrentContext = false
});
public async Task NotifyBankAccountVerifiedAsync(Provider provider)
{
var providerUsers = await providerUserRepository.GetManyByProviderAsync(provider.Id);
var providerAdmins = providerUsers.Where(providerUser => providerUser is
{
Type: ProviderUserType.ProviderAdmin,
Status: ProviderUserStatusType.Confirmed,
UserId: not null
}).ToList();
if (providerAdmins.Count > 0)
{
var tasks = providerAdmins.Select(providerAdmin => pushNotificationService.PushAsync(
new PushNotification<ProviderBankAccountVerifiedPushNotification>
{
Type = PushType.ProviderBankAccountVerified,
Target = NotificationTarget.User,
TargetId = providerAdmin.UserId!.Value,
Payload = new ProviderBankAccountVerifiedPushNotification
{
ProviderId = provider.Id,
AdminId = providerAdmin.UserId!.Value
},
ExcludeCurrentContext = false
}));
await Task.WhenAll(tasks);
}
}
public Task NotifyEnabledChangedAsync(Organization organization) =>
pushNotificationService.PushAsync(new PushNotification<OrganizationStatusPushNotification>
{
Type = PushType.SyncOrganizationStatusChanged,
Target = NotificationTarget.Organization,
TargetId = organization.Id,
Payload = new OrganizationStatusPushNotification
{
OrganizationId = organization.Id,
Enabled = organization.Enabled,
},
ExcludeCurrentContext = false,
});
}

View File

@ -0,0 +1,77 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Services;
using OneOf;
using Stripe;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class SetupIntentSucceededHandler(
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IPushNotificationAdapter pushNotificationAdapter,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
IStripeEventService stripeEventService) : ISetupIntentSucceededHandler
{
public async Task HandleAsync(Event parsedEvent)
{
var setupIntent = await stripeEventService.GetSetupIntent(
parsedEvent,
true,
["payment_method"]);
if (setupIntent is not
{
PaymentMethod.UsBankAccount: not null
})
{
return;
}
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
if (subscriberId == null)
{
return;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
OneOf<Organization, Provider> entity = organization != null ? organization : provider!;
await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod);
}
private async Task SetPaymentMethodAsync(
OneOf<Organization, Provider> subscriber,
PaymentMethod paymentMethod)
{
var customerId = subscriber.Match(
organization => organization.GatewayCustomerId,
provider => provider.GatewayCustomerId);
if (string.IsNullOrEmpty(customerId))
{
return;
}
await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id,
new PaymentMethodAttachOptions { Customer = customerId });
await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = paymentMethod.Id
}
});
await subscriber.Match(
async organization => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(organization),
async provider => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(provider));
}
}

View File

@ -3,88 +3,64 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class StripeEventProcessor : IStripeEventProcessor
public class StripeEventProcessor(
ILogger<StripeEventProcessor> logger,
ISubscriptionDeletedHandler subscriptionDeletedHandler,
ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
IUpcomingInvoiceHandler upcomingInvoiceHandler,
IChargeSucceededHandler chargeSucceededHandler,
IChargeRefundedHandler chargeRefundedHandler,
IPaymentSucceededHandler paymentSucceededHandler,
IPaymentFailedHandler paymentFailedHandler,
IInvoiceCreatedHandler invoiceCreatedHandler,
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler,
ISetupIntentSucceededHandler setupIntentSucceededHandler)
: IStripeEventProcessor
{
private readonly ILogger<StripeEventProcessor> _logger;
private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler;
private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler;
private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler;
private readonly IChargeSucceededHandler _chargeSucceededHandler;
private readonly IChargeRefundedHandler _chargeRefundedHandler;
private readonly IPaymentSucceededHandler _paymentSucceededHandler;
private readonly IPaymentFailedHandler _paymentFailedHandler;
private readonly IInvoiceCreatedHandler _invoiceCreatedHandler;
private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler;
private readonly ICustomerUpdatedHandler _customerUpdatedHandler;
private readonly IInvoiceFinalizedHandler _invoiceFinalizedHandler;
public StripeEventProcessor(
ILogger<StripeEventProcessor> logger,
ISubscriptionDeletedHandler subscriptionDeletedHandler,
ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
IUpcomingInvoiceHandler upcomingInvoiceHandler,
IChargeSucceededHandler chargeSucceededHandler,
IChargeRefundedHandler chargeRefundedHandler,
IPaymentSucceededHandler paymentSucceededHandler,
IPaymentFailedHandler paymentFailedHandler,
IInvoiceCreatedHandler invoiceCreatedHandler,
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler)
{
_logger = logger;
_subscriptionDeletedHandler = subscriptionDeletedHandler;
_subscriptionUpdatedHandler = subscriptionUpdatedHandler;
_upcomingInvoiceHandler = upcomingInvoiceHandler;
_chargeSucceededHandler = chargeSucceededHandler;
_chargeRefundedHandler = chargeRefundedHandler;
_paymentSucceededHandler = paymentSucceededHandler;
_paymentFailedHandler = paymentFailedHandler;
_invoiceCreatedHandler = invoiceCreatedHandler;
_paymentMethodAttachedHandler = paymentMethodAttachedHandler;
_customerUpdatedHandler = customerUpdatedHandler;
_invoiceFinalizedHandler = invoiceFinalizedHandler;
}
public async Task ProcessEventAsync(Event parsedEvent)
{
switch (parsedEvent.Type)
{
case HandledStripeWebhook.SubscriptionDeleted:
await _subscriptionDeletedHandler.HandleAsync(parsedEvent);
await subscriptionDeletedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.SubscriptionUpdated:
await _subscriptionUpdatedHandler.HandleAsync(parsedEvent);
await subscriptionUpdatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.UpcomingInvoice:
await _upcomingInvoiceHandler.HandleAsync(parsedEvent);
await upcomingInvoiceHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.ChargeSucceeded:
await _chargeSucceededHandler.HandleAsync(parsedEvent);
await chargeSucceededHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.ChargeRefunded:
await _chargeRefundedHandler.HandleAsync(parsedEvent);
await chargeRefundedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentSucceeded:
await _paymentSucceededHandler.HandleAsync(parsedEvent);
await paymentSucceededHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentFailed:
await _paymentFailedHandler.HandleAsync(parsedEvent);
await paymentFailedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.InvoiceCreated:
await _invoiceCreatedHandler.HandleAsync(parsedEvent);
await invoiceCreatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentMethodAttached:
await _paymentMethodAttachedHandler.HandleAsync(parsedEvent);
await paymentMethodAttachedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.CustomerUpdated:
await _customerUpdatedHandler.HandleAsync(parsedEvent);
await customerUpdatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.InvoiceFinalized:
await _invoiceFinalizedHandler.HandleAsync(parsedEvent);
await invoiceFinalizedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.SetupIntentSucceeded:
await setupIntentSucceededHandler.HandleAsync(parsedEvent);
break;
default:
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
break;
}
}

View File

@ -1,183 +1,122 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Billing.Constants;
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Stripe;
namespace Bit.Billing.Services.Implementations;
public class StripeEventService : IStripeEventService
public class StripeEventService(
GlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeFacade stripeFacade)
: IStripeEventService
{
private readonly GlobalSettings _globalSettings;
private readonly ILogger<StripeEventService> _logger;
private readonly IStripeFacade _stripeFacade;
public StripeEventService(
GlobalSettings globalSettings,
ILogger<StripeEventService> logger,
IStripeFacade stripeFacade)
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
_globalSettings = globalSettings;
_logger = logger;
_stripeFacade = stripeFacade;
}
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventCharge = Extract<Charge>(stripeEvent);
var charge = Extract<Charge>(stripeEvent);
if (!fresh)
{
return eventCharge;
return charge;
}
if (string.IsNullOrEmpty(eventCharge.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Charge for Event with ID '{eventId}' because no Charge ID was included in the Event.", stripeEvent.Id);
return eventCharge;
}
var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand });
if (charge == null)
{
throw new Exception(
$"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return charge;
return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand });
}
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var eventCustomer = Extract<Customer>(stripeEvent);
var customer = Extract<Customer>(stripeEvent);
if (!fresh)
{
return eventCustomer;
return customer;
}
if (string.IsNullOrEmpty(eventCustomer.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Customer for Event with ID '{eventId}' because no Customer ID was included in the Event.", stripeEvent.Id);
return eventCustomer;
}
var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand });
if (customer == null)
{
throw new Exception(
$"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return customer;
return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand });
}
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var eventInvoice = Extract<Invoice>(stripeEvent);
var invoice = Extract<Invoice>(stripeEvent);
if (!fresh)
{
return eventInvoice;
return invoice;
}
if (string.IsNullOrEmpty(eventInvoice.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Invoice for Event with ID '{eventId}' because no Invoice ID was included in the Event.", stripeEvent.Id);
return eventInvoice;
}
var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand });
if (invoice == null)
{
throw new Exception(
$"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return invoice;
return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand });
}
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false,
List<string>? expand = null)
{
var eventPaymentMethod = Extract<PaymentMethod>(stripeEvent);
var paymentMethod = Extract<PaymentMethod>(stripeEvent);
if (!fresh)
{
return eventPaymentMethod;
return paymentMethod;
}
if (string.IsNullOrEmpty(eventPaymentMethod.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Payment Method for Event with ID '{eventId}' because no Payment Method ID was included in the Event.", stripeEvent.Id);
return eventPaymentMethod;
}
var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
if (paymentMethod == null)
{
throw new Exception(
$"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return paymentMethod;
return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
}
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<SetupIntent> GetSetupIntent(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var eventSubscription = Extract<Subscription>(stripeEvent);
var setupIntent = Extract<SetupIntent>(stripeEvent);
if (!fresh)
{
return eventSubscription;
return setupIntent;
}
if (string.IsNullOrEmpty(eventSubscription.Id))
return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand });
}
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var subscription = Extract<Subscription>(stripeEvent);
if (!fresh)
{
_logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id);
return eventSubscription;
return subscription;
}
var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand });
if (subscription == null)
{
throw new Exception(
$"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return subscription;
return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand });
}
public async Task<bool> ValidateCloudRegion(Event stripeEvent)
{
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion;
var serverRegion = globalSettings.BaseServiceUri.CloudRegion;
var customerExpansion = new List<string> { "customer" };
var customerMetadata = stripeEvent.Type switch
{
HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated =>
(await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
(await GetSubscription(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded =>
(await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
(await GetCharge(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.UpcomingInvoice =>
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed
or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
(await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.PaymentMethodAttached =>
(await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
(await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.CustomerUpdated =>
(await GetCustomer(stripeEvent, true))?.Metadata,
(await GetCustomer(stripeEvent, true)).Metadata,
HandledStripeWebhook.SetupIntentSucceeded =>
await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
_ => null
};
@ -194,51 +133,69 @@ public class StripeEventService : IStripeEventService
/* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because
the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer'
expansion, we need to use the Customer ID on the event to retrieve the metadata. */
async Task<Dictionary<string, string>> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent)
async Task<Dictionary<string, string>?> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent)
{
var invoice = await GetInvoice(localStripeEvent);
var customer = !string.IsNullOrEmpty(invoice.CustomerId)
? await _stripeFacade.GetCustomer(invoice.CustomerId)
? await stripeFacade.GetCustomer(invoice.CustomerId)
: null;
return customer?.Metadata;
}
async Task<Dictionary<string, string>?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent)
{
var setupIntent = await GetSetupIntent(localStripeEvent);
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
if (subscriberId == null)
{
return null;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
if (organization is { GatewayCustomerId: not null })
{
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
return organizationCustomer.Metadata;
}
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
if (provider is not { GatewayCustomerId: not null })
{
return null;
}
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
return providerCustomer.Metadata;
}
}
private static T Extract<T>(Event stripeEvent)
{
if (stripeEvent.Data.Object is not T type)
{
throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'");
}
return type;
}
=> stripeEvent.Data.Object is not T type
? throw new Exception(
$"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'")
: type;
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
{
const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates;
if (customerMetadata is null)
{
return null;
}
if (customerMetadata.TryGetValue("region", out var value))
{
return value;
}
var miscasedRegionKey = customerMetadata.Keys
var incorrectlyCasedRegionKey = customerMetadata.Keys
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase));
if (miscasedRegionKey is null)
if (incorrectlyCasedRegionKey is null)
{
return defaultRegion;
}
_ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue);
_ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue);
return !string.IsNullOrWhiteSpace(regionValue)
? regionValue

View File

@ -16,6 +16,7 @@ public class StripeFacade : IStripeFacade
private readonly PaymentMethodService _paymentMethodService = new();
private readonly SubscriptionService _subscriptionService = new();
private readonly DiscountService _discountService = new();
private readonly SetupIntentService _setupIntentService = new();
private readonly TestClockService _testClockService = new();
public async Task<Charge> GetCharge(
@ -53,6 +54,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken);
public async Task<SetupIntent> GetSetupIntent(
string setupIntentId,
SetupIntentGetOptions setupIntentGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _setupIntentService.GetAsync(setupIntentId, setupIntentGetOptions, requestOptions, cancellationToken);
public async Task<StripeList<Invoice>> ListInvoices(
InvoiceListOptions options = null,
RequestOptions requestOptions = null,

View File

@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Quartz;
@ -25,7 +24,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IStripeFacade _stripeFacade;
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
private readonly IUserService _userService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
@ -35,6 +33,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ILogger<SubscriptionUpdatedHandler> _logger;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
public SubscriptionUpdatedHandler(
IStripeEventService stripeEventService,
@ -43,7 +42,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IStripeFacade stripeFacade,
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
IUserService userService,
IPushNotificationService pushNotificationService,
IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory,
IOrganizationEnableCommand organizationEnableCommand,
@ -52,7 +50,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IFeatureService featureService,
IProviderRepository providerRepository,
IProviderService providerService,
ILogger<SubscriptionUpdatedHandler> logger)
ILogger<SubscriptionUpdatedHandler> logger,
IPushNotificationAdapter pushNotificationAdapter)
{
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
@ -61,7 +60,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_stripeFacade = stripeFacade;
_organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand;
_userService = userService;
_pushNotificationService = pushNotificationService;
_organizationRepository = organizationRepository;
_providerRepository = providerRepository;
_schedulerFactory = schedulerFactory;
@ -72,6 +70,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_providerRepository = providerRepository;
_providerService = providerService;
_logger = logger;
_pushNotificationAdapter = pushNotificationAdapter;
}
/// <summary>
@ -125,7 +124,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization != null)
{
await _pushNotificationService.PushSyncOrganizationStatusAsync(organization);
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
}
break;
}

View File

@ -73,6 +73,7 @@ public class Startup
services.AddScoped<IPaymentMethodAttachedHandler, PaymentMethodAttachedHandler>();
services.AddScoped<IPaymentSucceededHandler, PaymentSucceededHandler>();
services.AddScoped<IInvoiceFinalizedHandler, InvoiceFinalizedHandler>();
services.AddScoped<ISetupIntentSucceededHandler, SetupIntentSucceededHandler>();
services.AddScoped<IStripeEventProcessor, StripeEventProcessor>();
// Identity
@ -111,6 +112,7 @@ public class Startup
services.AddScoped<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
services.AddScoped<IProviderEventService, ProviderEventService>();
services.AddScoped<IPushNotificationAdapter, PushNotificationAdapter>();
// Add Quartz services first
services.AddQuartz(q =>

View File

@ -2,9 +2,8 @@
public interface ISetupIntentCache
{
Task<string> Get(Guid subscriberId);
Task Remove(Guid subscriberId);
Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId);
Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId);
Task RemoveSetupIntentForSubscriber(Guid subscriberId);
Task Set(Guid subscriberId, string setupIntentId);
}

View File

@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Caches.Implementations;
@ -10,26 +7,41 @@ public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache) : ISetupIntentCache
{
public async Task<string> Get(Guid subscriberId)
public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId)
{
var cacheKey = GetCacheKey(subscriberId);
var cacheKey = GetCacheKeyBySubscriberId(subscriberId);
return await distributedCache.GetStringAsync(cacheKey);
}
public async Task Remove(Guid subscriberId)
public async Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId)
{
var cacheKey = GetCacheKey(subscriberId);
var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
var value = await distributedCache.GetStringAsync(cacheKey);
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId))
{
return null;
}
return subscriberId;
}
public async Task RemoveSetupIntentForSubscriber(Guid subscriberId)
{
var cacheKey = GetCacheKeyBySubscriberId(subscriberId);
await distributedCache.RemoveAsync(cacheKey);
}
public async Task Set(Guid subscriberId, string setupIntentId)
{
var cacheKey = GetCacheKey(subscriberId);
await distributedCache.SetStringAsync(cacheKey, setupIntentId);
var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId);
var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
await Task.WhenAll(
distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId),
distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString()));
}
private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}";
private static string GetCacheKeyBySetupIntentId(string setupIntentId) =>
$"subscriber_id_for_setup_intent_id_{setupIntentId}";
private static string GetCacheKeyBySubscriberId(Guid subscriberId) =>
$"setup_intent_id_for_subscriber_id_{subscriberId}";
}

View File

@ -285,7 +285,7 @@ public class GetOrganizationWarningsQuery(
private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization)
{
var setupIntentId = await setupIntentCache.Get(organization.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id);
if (string.IsNullOrEmpty(setupIntentId))
{

View File

@ -383,7 +383,7 @@ public class OrganizationBillingService(
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(organization.Id);
await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):

View File

@ -1,62 +0,0 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Payment.Commands;
public interface IVerifyBankAccountCommand
{
Task<BillingCommandResult<MaskedPaymentMethod>> Run(
ISubscriber subscriber,
string descriptorCode);
}
public class VerifyBankAccountCommand(
ILogger<VerifyBankAccountCommand> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter) : BaseBillingCommand<VerifyBankAccountCommand>(logger), IVerifyBankAccountCommand
{
private readonly ILogger<VerifyBankAccountCommand> _logger = logger;
protected override Conflict DefaultConflict
=> new("We had a problem verifying your bank account. Please contact support for assistance.");
public Task<BillingCommandResult<MaskedPaymentMethod>> Run(
ISubscriber subscriber,
string descriptorCode) => HandleAsync<MaskedPaymentMethod>(async () =>
{
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
_logger.LogError(
"{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account",
CommandName, subscriber.Id);
return DefaultConflict;
}
await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId,
new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode });
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId,
new SetupIntentGetOptions { Expand = ["payment_method"] });
var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = setupIntent.PaymentMethodId
}
});
return MaskedPaymentMethod.From(paymentMethod.UsBankAccount);
});
}

View File

@ -10,7 +10,7 @@ public record MaskedBankAccount
{
public required string BankName { get; init; }
public required string Last4 { get; init; }
public required bool Verified { get; init; }
public string? HostedVerificationUrl { get; init; }
public string Type => "bankAccount";
}
@ -39,8 +39,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount
{
BankName = bankAccount.BankName,
Last4 = bankAccount.Last4,
Verified = bankAccount.Status == "verified"
Last4 = bankAccount.Last4
};
public static MaskedPaymentMethod From(Card card) => new MaskedCard
@ -61,7 +60,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
{
BankName = setupIntent.PaymentMethod.UsBankAccount.BankName,
Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4,
Verified = false
HostedVerificationUrl = setupIntent.NextAction?.VerifyWithMicrodeposits?.HostedVerificationUrl
};
public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard
@ -74,8 +73,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount
{
BankName = bankAccount.BankName,
Last4 = bankAccount.Last4,
Verified = true
Last4 = bankAccount.Last4
};
public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email };

View File

@ -33,6 +33,7 @@ public class GetPaymentMethodQuery(
return null;
}
// First check for PayPal
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
@ -47,6 +48,23 @@ public class GetPaymentMethodQuery(
return null;
}
// Then check for a bank account pending verification
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (!string.IsNullOrEmpty(setupIntentId))
{
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
if (setupIntent.IsUnverifiedBankAccount())
{
return MaskedPaymentMethod.From(setupIntent);
}
}
// Then check the default payment method
var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null
? customer.InvoiceSettings.DefaultPaymentMethod.Type switch
{
@ -61,40 +79,12 @@ public class GetPaymentMethodQuery(
return paymentMethod;
}
if (customer.DefaultSource != null)
return customer.DefaultSource switch
{
paymentMethod = customer.DefaultSource switch
{
Card card => MaskedPaymentMethod.From(card),
BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount),
Source { Card: not null } source => MaskedPaymentMethod.From(source.Card),
_ => null
};
if (paymentMethod != null)
{
return paymentMethod;
}
}
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return null;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
// ReSharper disable once ConvertIfStatementToReturnStatement
if (!setupIntent.IsUnverifiedBankAccount())
{
return null;
}
return MaskedPaymentMethod.From(setupIntent);
Card card => MaskedPaymentMethod.From(card),
BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount),
Source { Card: not null } source => MaskedPaymentMethod.From(source.Card),
_ => null
};
}
}

View File

@ -14,7 +14,6 @@ public static class Registrations
services.AddTransient<ICreateBitPayInvoiceForCreditCommand, CreateBitPayInvoiceForCreditCommand>();
services.AddTransient<IUpdateBillingAddressCommand, UpdateBillingAddressCommand>();
services.AddTransient<IUpdatePaymentMethodCommand, UpdatePaymentMethodCommand>();
services.AddTransient<IVerifyBankAccountCommand, VerifyBankAccountCommand>();
// Queries
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();

View File

@ -283,7 +283,7 @@ public class PremiumUserBillingService(
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(user.Id);
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):

View File

@ -858,7 +858,7 @@ public class SubscriberService(
ISubscriber subscriber,
string descriptorCode)
{
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
@ -986,7 +986,7 @@ 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.
*/
var setupIntentId = await setupIntentCache.Get(subscriberId);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriberId);
if (string.IsNullOrEmpty(setupIntentId))
{

View File

@ -86,3 +86,14 @@ public class OrganizationCollectionManagementPushNotification
public bool LimitCollectionDeletion { get; init; }
public bool LimitItemDeletion { get; init; }
}
public class OrganizationBankAccountVerifiedPushNotification
{
public Guid OrganizationId { get; set; }
}
public class ProviderBankAccountVerifiedPushNotification
{
public Guid ProviderId { get; set; }
public Guid AdminId { get; set; }
}

View File

@ -399,20 +399,6 @@ public interface IPushNotificationService
ExcludeCurrentContext = true,
});
Task PushSyncOrganizationStatusAsync(Organization organization)
=> PushAsync(new PushNotification<OrganizationStatusPushNotification>
{
Type = PushType.SyncOrganizationStatusChanged,
Target = NotificationTarget.Organization,
TargetId = organization.Id,
Payload = new OrganizationStatusPushNotification
{
OrganizationId = organization.Id,
Enabled = organization.Enabled,
},
ExcludeCurrentContext = false,
});
Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization)
=> PushAsync(new PushNotification<OrganizationCollectionManagementPushNotification>
{

View File

@ -4,16 +4,16 @@
namespace Bit.Core.Enums;
/// <summary>
///
///
/// </summary>
/// <remarks>
/// <para>
/// When adding a new enum member you must annotate it with a <see cref="NotificationInfoAttribute"/>
/// When adding a new enum member you must annotate it with a <see cref="NotificationInfoAttribute"/>
/// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced
/// in <see cref="NotificationInfoAttribute"/>.
/// </para>
/// <para>
/// You may and are
/// You may and are
/// </para>
/// </remarks>
public enum PushType : byte
@ -90,4 +90,10 @@ public enum PushType : byte
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
RefreshSecurityTasks = 22,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
OrganizationBankAccountVerified = 23,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
ProviderBankAccountVerified = 24
}

View File

@ -106,6 +106,20 @@ public static class HubHelpers
await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken);
break;
case PushType.OrganizationBankAccountVerified:
var organizationBankAccountVerifiedNotification =
JsonSerializer.Deserialize<PushNotificationData<OrganizationBankAccountVerifiedPushNotification>>(
notificationJson, _deserializerOptions);
await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken);
break;
case PushType.ProviderBankAccountVerified:
var providerBankAccountVerifiedNotification =
JsonSerializer.Deserialize<PushNotificationData<ProviderBankAccountVerifiedPushNotification>>(
notificationJson, _deserializerOptions);
await hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString())
.SendAsync(_receiveMessageMethod, providerBankAccountVerifiedNotification, cancellationToken);
break;
case PushType.Notification:
case PushType.NotificationStatus:
var notificationData = JsonSerializer.Deserialize<PushNotificationData<NotificationPushNotification>>(
@ -144,6 +158,7 @@ public static class HubHelpers
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
break;
default:
logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
break;
}
}

View File

@ -0,0 +1,242 @@
using Bit.Billing.Services;
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.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Stripe;
using Xunit;
using Event = Stripe.Event;
namespace Bit.Billing.Test.Services;
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 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()
{
_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(
_organizationRepository,
_providerRepository,
_pushNotificationAdapter,
_setupIntentCache,
_stripeAdapter,
_stripeEventService);
}
[Fact]
public async Task HandleAsync_PaymentMethodNotUSBankAccount_Returns()
{
// Arrange
var setupIntent = CreateSetupIntent(hasUSBankAccount: false);
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any<string>());
await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync(
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_NoSubscriberIdInCache_Returns()
{
// Arrange
var setupIntent = CreateSetupIntent();
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns((Guid?)null);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync(
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_ValidOrganization_AttachesPaymentMethodAndSendsNotification()
{
// Arrange
var organizationId = Guid.NewGuid();
var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = "cus_test" };
var setupIntent = CreateSetupIntent();
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(organization);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _stripeAdapter.Received(1).PaymentMethodAttachAsync(
"pm_test",
Arg.Is<PaymentMethodAttachOptions>(o => o.Customer == organization.GatewayCustomerId));
await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(organization);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
}
[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();
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
.Returns((Organization?)null);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _stripeAdapter.Received(1).PaymentMethodAttachAsync(
"pm_test",
Arg.Is<PaymentMethodAttachOptions>(o => o.Customer == provider.GatewayCustomerId));
await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(provider);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Organization>());
}
[Fact]
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();
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(organization);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync(
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_ProviderWithoutGatewayCustomerId_DoesNotAttachPaymentMethod()
{
// Arrange
var providerId = Guid.NewGuid();
var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = null };
var setupIntent = CreateSetupIntent();
_stripeEventService.GetSetupIntent(
_mockEvent,
true,
Arg.Is<List<string>>(options => options.SequenceEqual(_expand)))
.Returns(setupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id)
.Returns(providerId);
_organizationRepository.GetByIdAsync(providerId)
.Returns((Organization?)null);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
// Act
await _handler.HandleAsync(_mockEvent);
// Assert
await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync(
Arg.Any<string>(), Arg.Any<PaymentMethodAttachOptions>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Organization>());
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any<Provider>());
}
private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true)
{
var paymentMethod = new PaymentMethod
{
Id = "pm_test",
Type = "us_bank_account",
UsBankAccount = hasUSBankAccount ? new PaymentMethodUsBankAccount() : null
};
var setupIntent = new SetupIntent
{
Id = "seti_test",
PaymentMethod = paymentMethod
};
return setupIntent;
}
}

View File

@ -1,8 +1,9 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Billing.Test.Utilities;
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;
@ -11,6 +12,9 @@ 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;
@ -20,8 +24,11 @@ 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>>(), _stripeFacade);
_stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade);
}
#region GetCharge
@ -29,50 +36,44 @@ public class StripeEventServiceTests
public async Task GetCharge_EventNotChargeRelated_ThrowsException()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" });
// Act
var function = async () => await _stripeEventService.GetCharge(stripeEvent);
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetCharge(stripeEvent));
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(
Arg.Any<string>(),
Arg.Any<ChargeGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<ChargeGetOptions>());
}
[Fact]
public async Task GetCharge_NotFresh_ReturnsEventCharge()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded);
var mockCharge = new Charge { Id = "ch_test", Amount = 1000 };
var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge);
// Act
var charge = await _stripeEventService.GetCharge(stripeEvent);
// Assert
Assert.Equivalent(stripeEvent.Data.Object as Charge, charge, true);
Assert.Equal(mockCharge.Id, charge.Id);
Assert.Equal(mockCharge.Amount, charge.Amount);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(
Arg.Any<string>(),
Arg.Any<ChargeGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<ChargeGetOptions>());
}
[Fact]
public async Task GetCharge_Fresh_Expand_ReturnsAPICharge()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded);
var eventCharge = new Charge { Id = "ch_test", Amount = 1000 };
var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", eventCharge);
var eventCharge = stripeEvent.Data.Object as Charge;
var apiCharge = Copy(eventCharge);
var apiCharge = new Charge { Id = "ch_test", Amount = 2000 };
var expand = new List<string> { "customer" };
@ -90,9 +91,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetCharge(
apiCharge.Id,
Arg.Is<ChargeGetOptions>(options => options.Expand == expand),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Is<ChargeGetOptions>(options => options.Expand == expand));
}
#endregion
@ -101,50 +100,44 @@ public class StripeEventServiceTests
public async Task GetCustomer_EventNotCustomerRelated_ThrowsException()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" });
// Act
var function = async () => await _stripeEventService.GetCustomer(stripeEvent);
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetCustomer(stripeEvent));
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(
Arg.Any<string>(),
Arg.Any<CustomerGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<CustomerGetOptions>());
}
[Fact]
public async Task GetCustomer_NotFresh_ReturnsEventCustomer()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated);
var mockCustomer = new Customer { Id = "cus_test", Email = "test@example.com" };
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer);
// Act
var customer = await _stripeEventService.GetCustomer(stripeEvent);
// Assert
Assert.Equivalent(stripeEvent.Data.Object as Customer, customer, true);
Assert.Equal(mockCustomer.Id, customer.Id);
Assert.Equal(mockCustomer.Email, customer.Email);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(
Arg.Any<string>(),
Arg.Any<CustomerGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<CustomerGetOptions>());
}
[Fact]
public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated);
var eventCustomer = new Customer { Id = "cus_test", Email = "test@example.com" };
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", eventCustomer);
var eventCustomer = stripeEvent.Data.Object as Customer;
var apiCustomer = Copy(eventCustomer);
var apiCustomer = new Customer { Id = "cus_test", Email = "updated@example.com" };
var expand = new List<string> { "subscriptions" };
@ -162,9 +155,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetCustomer(
apiCustomer.Id,
Arg.Is<CustomerGetOptions>(options => options.Expand == expand),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Is<CustomerGetOptions>(options => options.Expand == expand));
}
#endregion
@ -173,50 +164,44 @@ public class StripeEventServiceTests
public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated);
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act
var function = async () => await _stripeEventService.GetInvoice(stripeEvent);
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetInvoice(stripeEvent));
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(
Arg.Any<string>(),
Arg.Any<InvoiceGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<InvoiceGetOptions>());
}
[Fact]
public async Task GetInvoice_NotFresh_ReturnsEventInvoice()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var mockInvoice = new Invoice { Id = "in_test", AmountDue = 1000 };
var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice);
// Act
var invoice = await _stripeEventService.GetInvoice(stripeEvent);
// Assert
Assert.Equivalent(stripeEvent.Data.Object as Invoice, invoice, true);
Assert.Equal(mockInvoice.Id, invoice.Id);
Assert.Equal(mockInvoice.AmountDue, invoice.AmountDue);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(
Arg.Any<string>(),
Arg.Any<InvoiceGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<InvoiceGetOptions>());
}
[Fact]
public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var eventInvoice = new Invoice { Id = "in_test", AmountDue = 1000 };
var stripeEvent = CreateMockEvent("evt_test", "invoice.created", eventInvoice);
var eventInvoice = stripeEvent.Data.Object as Invoice;
var apiInvoice = Copy(eventInvoice);
var apiInvoice = new Invoice { Id = "in_test", AmountDue = 2000 };
var expand = new List<string> { "customer" };
@ -234,9 +219,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetInvoice(
apiInvoice.Id,
Arg.Is<InvoiceGetOptions>(options => options.Expand == expand),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Is<InvoiceGetOptions>(options => options.Expand == expand));
}
#endregion
@ -245,50 +228,44 @@ public class StripeEventServiceTests
public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated);
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act
var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent);
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetPaymentMethod(stripeEvent));
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(
Arg.Any<string>(),
Arg.Any<PaymentMethodGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<PaymentMethodGetOptions>());
}
[Fact]
public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached);
var mockPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" };
var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod);
// Act
var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent);
// Assert
Assert.Equivalent(stripeEvent.Data.Object as PaymentMethod, paymentMethod, true);
Assert.Equal(mockPaymentMethod.Id, paymentMethod.Id);
Assert.Equal(mockPaymentMethod.Type, paymentMethod.Type);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(
Arg.Any<string>(),
Arg.Any<PaymentMethodGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<PaymentMethodGetOptions>());
}
[Fact]
public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached);
var eventPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" };
var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", eventPaymentMethod);
var eventPaymentMethod = stripeEvent.Data.Object as PaymentMethod;
var apiPaymentMethod = Copy(eventPaymentMethod);
var apiPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" };
var expand = new List<string> { "customer" };
@ -306,9 +283,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetPaymentMethod(
apiPaymentMethod.Id,
Arg.Is<PaymentMethodGetOptions>(options => options.Expand == expand),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Is<PaymentMethodGetOptions>(options => options.Expand == expand));
}
#endregion
@ -317,50 +292,44 @@ public class StripeEventServiceTests
public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated);
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act
var function = async () => await _stripeEventService.GetSubscription(stripeEvent);
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetSubscription(stripeEvent));
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(
Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task GetSubscription_NotFresh_ReturnsEventSubscription()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated);
var mockSubscription = new Subscription { Id = "sub_test", Status = "active" };
var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription);
// Act
var subscription = await _stripeEventService.GetSubscription(stripeEvent);
// Assert
Assert.Equivalent(stripeEvent.Data.Object as Subscription, subscription, true);
Assert.Equal(mockSubscription.Id, subscription.Id);
Assert.Equal(mockSubscription.Status, subscription.Status);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(
Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated);
var eventSubscription = new Subscription { Id = "sub_test", Status = "active" };
var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", eventSubscription);
var eventSubscription = stripeEvent.Data.Object as Subscription;
var apiSubscription = Copy(eventSubscription);
var apiSubscription = new Subscription { Id = "sub_test", Status = "canceled" };
var expand = new List<string> { "customer" };
@ -378,9 +347,71 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetSubscription(
apiSubscription.Id,
Arg.Is<SubscriptionGetOptions>(options => options.Expand == expand),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
Arg.Is<SubscriptionGetOptions>(options => options.Expand == expand));
}
#endregion
#region GetSetupIntent
[Fact]
public async Task GetSetupIntent_EventNotSetupIntentRelated_ThrowsException()
{
// Arrange
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetSetupIntent(stripeEvent));
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(SetupIntent)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent(
Arg.Any<string>(),
Arg.Any<SetupIntentGetOptions>());
}
[Fact]
public async Task GetSetupIntent_NotFresh_ReturnsEventSetupIntent()
{
// Arrange
var mockSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
// Act
var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent);
// Assert
Assert.Equal(mockSetupIntent.Id, setupIntent.Id);
Assert.Equal(mockSetupIntent.Status, setupIntent.Status);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent(
Arg.Any<string>(),
Arg.Any<SetupIntentGetOptions>());
}
[Fact]
public async Task GetSetupIntent_Fresh_Expand_ReturnsAPISetupIntent()
{
// Arrange
var eventSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" };
var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", eventSetupIntent);
var apiSetupIntent = new SetupIntent { Id = "seti_test", Status = "requires_action" };
var expand = new List<string> { "customer" };
_stripeFacade.GetSetupIntent(
apiSetupIntent.Id,
Arg.Is<SetupIntentGetOptions>(options => options.Expand == expand))
.Returns(apiSetupIntent);
// Act
var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent, true, expand);
// Assert
Assert.Equal(apiSetupIntent, setupIntent);
Assert.NotSame(eventSetupIntent, setupIntent);
await _stripeFacade.Received().GetSetupIntent(
apiSetupIntent.Id,
Arg.Is<SetupIntentGetOptions>(options => options.Expand == expand));
}
#endregion
@ -389,18 +420,16 @@ public class StripeEventServiceTests
public async Task ValidateCloudRegion_SubscriptionUpdated_Success()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated);
var mockSubscription = new Subscription { Id = "sub_test" };
var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription);
var subscription = Copy(stripeEvent.Data.Object as Subscription);
var customer = await GetCustomerAsync();
subscription.Customer = customer;
var customer = CreateMockCustomer();
mockSubscription.Customer = customer;
_stripeFacade.GetSubscription(
subscription.Id,
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
.Returns(mockSubscription);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -409,28 +438,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_ChargeSucceeded_Success()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded);
var mockCharge = new Charge { Id = "ch_test" };
var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge);
var charge = Copy(stripeEvent.Data.Object as Charge);
var customer = await GetCustomerAsync();
charge.Customer = customer;
var customer = CreateMockCustomer();
mockCharge.Customer = customer;
_stripeFacade.GetCharge(
charge.Id,
mockCharge.Id,
Arg.Any<ChargeGetOptions>())
.Returns(charge);
.Returns(mockCharge);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -439,24 +464,21 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCharge(
charge.Id,
Arg.Any<ChargeGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockCharge.Id,
Arg.Any<ChargeGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_UpcomingInvoice_Success()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceUpcoming);
var mockInvoice = new Invoice { Id = "in_test", CustomerId = "cus_test" };
var stripeEvent = CreateMockEvent("evt_test", "invoice.upcoming", mockInvoice);
var invoice = Copy(stripeEvent.Data.Object as Invoice);
var customer = await GetCustomerAsync();
var customer = CreateMockCustomer();
_stripeFacade.GetCustomer(
invoice.CustomerId,
mockInvoice.CustomerId,
Arg.Any<CustomerGetOptions>())
.Returns(customer);
@ -467,28 +489,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCustomer(
invoice.CustomerId,
Arg.Any<CustomerGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockInvoice.CustomerId,
Arg.Any<CustomerGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_InvoiceCreated_Success()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var mockInvoice = new Invoice { Id = "in_test" };
var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice);
var invoice = Copy(stripeEvent.Data.Object as Invoice);
var customer = await GetCustomerAsync();
invoice.Customer = customer;
var customer = CreateMockCustomer();
mockInvoice.Customer = customer;
_stripeFacade.GetInvoice(
invoice.Id,
mockInvoice.Id,
Arg.Any<InvoiceGetOptions>())
.Returns(invoice);
.Returns(mockInvoice);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -497,28 +515,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetInvoice(
invoice.Id,
Arg.Any<InvoiceGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockInvoice.Id,
Arg.Any<InvoiceGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_PaymentMethodAttached_Success()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached);
var mockPaymentMethod = new PaymentMethod { Id = "pm_test" };
var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod);
var paymentMethod = Copy(stripeEvent.Data.Object as PaymentMethod);
var customer = await GetCustomerAsync();
paymentMethod.Customer = customer;
var customer = CreateMockCustomer();
mockPaymentMethod.Customer = customer;
_stripeFacade.GetPaymentMethod(
paymentMethod.Id,
mockPaymentMethod.Id,
Arg.Any<PaymentMethodGetOptions>())
.Returns(paymentMethod);
.Returns(mockPaymentMethod);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -527,24 +541,21 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetPaymentMethod(
paymentMethod.Id,
Arg.Any<PaymentMethodGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockPaymentMethod.Id,
Arg.Any<PaymentMethodGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_CustomerUpdated_Success()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated);
var customer = Copy(stripeEvent.Data.Object as Customer);
var mockCustomer = CreateMockCustomer();
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer);
_stripeFacade.GetCustomer(
customer.Id,
mockCustomer.Id,
Arg.Any<CustomerGetOptions>())
.Returns(customer);
.Returns(mockCustomer);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -553,29 +564,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCustomer(
customer.Id,
Arg.Any<CustomerGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockCustomer.Id,
Arg.Any<CustomerGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated);
var mockSubscription = new Subscription { Id = "sub_test" };
var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription);
var subscription = Copy(stripeEvent.Data.Object as Subscription);
var customer = await GetCustomerAsync();
customer.Metadata = null;
subscription.Customer = customer;
var customer = new Customer { Id = "cus_test", Metadata = null };
mockSubscription.Customer = customer;
_stripeFacade.GetSubscription(
subscription.Id,
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
.Returns(mockSubscription);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -584,29 +590,24 @@ public class StripeEventServiceTests
Assert.False(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated);
var mockSubscription = new Subscription { Id = "sub_test" };
var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription);
var subscription = Copy(stripeEvent.Data.Object as Subscription);
var customer = await GetCustomerAsync();
customer.Metadata = new Dictionary<string, string>();
subscription.Customer = customer;
var customer = new Customer { Id = "cus_test", Metadata = new Dictionary<string, string>() };
mockSubscription.Customer = customer;
_stripeFacade.GetSubscription(
subscription.Id,
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
.Returns(mockSubscription);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -615,32 +616,28 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue()
public async Task ValidateCloudRegion_MetadataIncorrectlyCasedRegion_ReturnsTrue()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated);
var mockSubscription = new Subscription { Id = "sub_test" };
var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription);
var subscription = Copy(stripeEvent.Data.Object as Subscription);
var customer = await GetCustomerAsync();
customer.Metadata = new Dictionary<string, string>
var customer = new Customer
{
{ "Region", "US" }
Id = "cus_test",
Metadata = new Dictionary<string, string> { { "Region", "US" } }
};
subscription.Customer = customer;
mockSubscription.Customer = customer;
_stripeFacade.GetSubscription(
subscription.Id,
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
.Returns(mockSubscription);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@ -649,31 +646,209 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>());
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationCustomer_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();
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns(organizationId);
_organizationRepository.GetByIdAsync(organizationId)
.Returns(mockOrganization);
_stripeFacade.GetCustomer(organizationCustomerId)
.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(organizationId);
await _stripeFacade.Received(1).GetCustomer(organizationCustomerId);
}
[Fact]
public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderCustomer_Success()
{
// 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 stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent);
_setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id)
.Returns((Guid?)null);
// Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// 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>());
}
#endregion
private static T Copy<T>(T input)
private static Event CreateMockEvent<T>(string id, string type, T dataObject) where T : IStripeEntity
{
var copy = (T)Activator.CreateInstance(typeof(T));
var properties = input.GetType().GetProperties();
foreach (var property in properties)
return new Event
{
var value = property.GetValue(input);
copy!
.GetType()
.GetProperty(property.Name)!
.SetValue(copy, value);
}
return copy;
Id = id,
Type = type,
Data = new EventData
{
Object = (IHasObject)dataObject
}
};
}
private static async Task<Customer> GetCustomerAsync()
=> (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer;
private static Customer CreateMockCustomer()
{
return new Customer
{
Id = "cus_test",
Metadata = new Dictionary<string, string> { { "region", "US" } }
};
}
}

View File

@ -11,7 +11,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
@ -33,7 +32,6 @@ public class SubscriptionUpdatedHandlerTests
private readonly IStripeFacade _stripeFacade;
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
private readonly IUserService _userService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
@ -42,6 +40,7 @@ public class SubscriptionUpdatedHandlerTests
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly IScheduler _scheduler;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
private readonly SubscriptionUpdatedHandler _sut;
public SubscriptionUpdatedHandlerTests()
@ -53,7 +52,6 @@ public class SubscriptionUpdatedHandlerTests
_organizationSponsorshipRenewCommand = Substitute.For<IOrganizationSponsorshipRenewCommand>();
_userService = Substitute.For<IUserService>();
_providerService = Substitute.For<IProviderService>();
_pushNotificationService = Substitute.For<IPushNotificationService>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
var schedulerFactory = Substitute.For<ISchedulerFactory>();
_organizationEnableCommand = Substitute.For<IOrganizationEnableCommand>();
@ -64,6 +62,7 @@ public class SubscriptionUpdatedHandlerTests
_providerService = Substitute.For<IProviderService>();
var logger = Substitute.For<ILogger<SubscriptionUpdatedHandler>>();
_scheduler = Substitute.For<IScheduler>();
_pushNotificationAdapter = Substitute.For<IPushNotificationAdapter>();
schedulerFactory.GetScheduler().Returns(_scheduler);
@ -74,7 +73,6 @@ public class SubscriptionUpdatedHandlerTests
_stripeFacade,
_organizationSponsorshipRenewCommand,
_userService,
_pushNotificationService,
_organizationRepository,
schedulerFactory,
_organizationEnableCommand,
@ -83,7 +81,8 @@ public class SubscriptionUpdatedHandlerTests
_featureService,
_providerRepository,
_providerService,
logger);
logger,
_pushNotificationAdapter);
}
[Fact]
@ -540,8 +539,8 @@ public class SubscriptionUpdatedHandlerTests
.EnableAsync(organizationId);
await _organizationService.Received(1)
.UpdateExpirationDateAsync(organizationId, currentPeriodEnd);
await _pushNotificationService.Received(1)
.PushSyncOrganizationStatusAsync(organization);
await _pushNotificationAdapter.Received(1)
.NotifyEnabledChangedAsync(organization);
}
[Fact]

View File

@ -71,7 +71,7 @@ public class GetOrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().Get(organization.Id).Returns((string?)null);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null);
var response = await sutProvider.Sut.Run(organization);
@ -109,7 +109,7 @@ public class GetOrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().Get(organization.Id).Returns(setupIntentId);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(
options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{

View File

@ -74,7 +74,10 @@ public class UpdatePaymentMethodCommandTests
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits
{
HostedVerificationUrl = "https://example.com"
}
},
Status = "requires_action"
};
@ -95,7 +98,7 @@ public class UpdatePaymentMethodCommandTests
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
}
@ -133,7 +136,10 @@ public class UpdatePaymentMethodCommandTests
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits
{
HostedVerificationUrl = "https://example.com"
}
},
Status = "requires_action"
};
@ -154,7 +160,7 @@ public class UpdatePaymentMethodCommandTests
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _subscriberService.Received(1).CreateStripeCustomer(organization);
@ -199,7 +205,10 @@ public class UpdatePaymentMethodCommandTests
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits
{
HostedVerificationUrl = "https://example.com"
}
},
Status = "requires_action"
};
@ -220,7 +229,7 @@ public class UpdatePaymentMethodCommandTests
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id);
await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, Arg.Is<CustomerUpdateOptions>(options =>

View File

@ -1,81 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Commands;
public class VerifyBankAccountCommandTests
{
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly VerifyBankAccountCommand _command;
public VerifyBankAccountCommandTests()
{
_command = new VerifyBankAccountCommand(
Substitute.For<ILogger<VerifyBankAccountCommand>>(),
_setupIntentCache,
_stripeAdapter);
}
[Fact]
public async Task Run_MakesCorrectInvocations_ReturnsMaskedBankAccount()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
GatewayCustomerId = "cus_123"
};
const string setupIntentId = "seti_123";
_setupIntentCache.Get(organization.Id).Returns(setupIntentId);
var setupIntent = new SetupIntent
{
Id = setupIntentId,
PaymentMethodId = "pm_123",
PaymentMethod =
new PaymentMethod
{
Id = "pm_123",
Type = "us_bank_account",
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
Status = "requires_action"
};
_stripeAdapter.SetupIntentGet(setupIntentId,
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method"))).Returns(setupIntent);
_stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
Arg.Is<PaymentMethodAttachOptions>(options => options.Customer == organization.GatewayCustomerId))
.Returns(setupIntent.PaymentMethod);
var result = await _command.Run(organization, "DESCRIPTOR_CODE");
Assert.True(result.IsT0);
var maskedPaymentMethod = result.AsT0;
Assert.True(maskedPaymentMethod.IsT0);
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified);
await _stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id,
Arg.Is<SetupIntentVerifyMicrodepositsOptions>(options => options.DescriptorCode == "DESCRIPTOR_CODE"));
await _stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId));
}
}

View File

@ -13,7 +13,7 @@ public class MaskedPaymentMethodTests
{
BankName = "Chase",
Last4 = "9999",
Verified = true
HostedVerificationUrl = "https://example.com"
};
var json = JsonSerializer.Serialize(input);
@ -32,7 +32,7 @@ public class MaskedPaymentMethodTests
{
BankName = "Chase",
Last4 = "9999",
Verified = true
HostedVerificationUrl = "https://example.com"
};
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

View File

@ -108,7 +108,7 @@ public class GetPaymentMethodQueryTests
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified);
Assert.Null(maskedBankAccount.HostedVerificationUrl);
}
[Fact]
@ -142,7 +142,7 @@ public class GetPaymentMethodQueryTests
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified);
Assert.Null(maskedBankAccount.HostedVerificationUrl);
}
[Fact]
@ -163,7 +163,7 @@ public class GetPaymentMethodQueryTests
Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer);
_setupIntentCache.Get(organization.Id).Returns("seti_123");
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
@ -177,7 +177,10 @@ public class GetPaymentMethodQueryTests
},
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits
{
HostedVerificationUrl = "https://example.com"
}
},
Status = "requires_action"
});
@ -189,7 +192,7 @@ public class GetPaymentMethodQueryTests
var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified);
Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
}
[Fact]

View File

@ -670,7 +670,7 @@ public class SubscriberServiceTests
}
};
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntent.Id);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntent.Id,
Arg.Is<SetupIntentGetOptions>(options => options.Expand.Contains("payment_method"))).Returns(setupIntent);
@ -1876,7 +1876,7 @@ public class SubscriberServiceTests
PaymentMethodId = "payment_method_id"
};
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntent.Id);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();

View File

@ -651,31 +651,6 @@ public class AzureQueuePushEngineTests
);
}
[Fact]
public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
Enabled = true,
};
var expectedPayload = new JsonObject
{
["Type"] = 18,
["Payload"] = new JsonObject
{
["OrganizationId"] = organization.Id,
["Enabled"] = organization.Enabled,
},
};
await VerifyNotificationAsync(
async sut => await sut.PushSyncOrganizationStatusAsync(organization),
expectedPayload
);
}
[Fact]
public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse()
{

View File

@ -413,21 +413,6 @@ public abstract class PushTestBase
);
}
[Fact]
public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
Enabled = true,
};
await VerifyNotificationAsync(
async sut => await sut.PushSyncOrganizationStatusAsync(organization),
GetPushSyncOrganizationStatusResponsePayload(organization)
);
}
[Fact]
public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse()
{