1
0
mirror of https://github.com/bitwarden/server synced 2025-12-19 17:53:44 +00:00

[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 b8743ab3b5.

* Revert "Run dotnet format"

This reverts commit 5c861b0b72.

* Revert "TEMP: Add logging for deployment check"

This reverts commit 0a88acd6a1.

* 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
42 changed files with 1136 additions and 814 deletions

View File

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

View File

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

View File

@@ -25,8 +25,7 @@ public class OrganizationBillingVNextController(
IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery, IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
{ {
[Authorize<ManageOrganizationBillingRequirement>] [Authorize<ManageOrganizationBillingRequirement>]
[HttpGet("address")] [HttpGet("address")]
@@ -96,17 +95,6 @@ public class OrganizationBillingVNextController(
return Handle(result); 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>] [Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")] [HttpGet("warnings")]
[InjectOrganization] [InjectOrganization]

View File

@@ -23,8 +23,7 @@ public class ProviderBillingVNextController(
IGetProviderWarningsQuery getProviderWarningsQuery, IGetProviderWarningsQuery getProviderWarningsQuery,
IProviderService providerService, IProviderService providerService,
IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
{ {
[HttpGet("address")] [HttpGet("address")]
[InjectProvider(ProviderUserType.ProviderAdmin)] [InjectProvider(ProviderUserType.ProviderAdmin)]
@@ -97,16 +96,6 @@ public class ProviderBillingVNextController(
return Handle(result); 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")] [HttpGet("warnings")]
[InjectProvider(ProviderUserType.ServiceUser)] [InjectProvider(ProviderUserType.ServiceUser)]
public async Task<IResult> GetWarningsAsync( 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 PaymentMethodAttached = "payment_method.attached";
public const string CustomerUpdated = "customer.updated"; public const string CustomerUpdated = "customer.updated";
public const string InvoiceFinalized = "invoice.finalized"; 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 using Stripe;
#nullable disable
using Stripe;
namespace Bit.Billing.Services; namespace Bit.Billing.Services;
@@ -13,12 +10,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options. /// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary> /// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param> /// <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> /// <param name="expand">Optionally provided to expand the fresh charge object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Charge"/>.</returns> /// <returns>A Stripe <see cref="Charge"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a charge object.</exception> Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <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);
/// <summary> /// <summary>
/// Extracts the <see cref="Customer"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, /// 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. /// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary> /// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param> /// <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> /// <param name="expand">Optionally provided to expand the fresh customer object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Customer"/>.</returns> /// <returns>A Stripe <see cref="Customer"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a customer object.</exception> Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <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);
/// <summary> /// <summary>
/// Extracts the <see cref="Invoice"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, /// 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. /// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary> /// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param> /// <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> /// <param name="expand">Optionally provided to expand the fresh invoice object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Invoice"/>.</returns> /// <returns>A Stripe <see cref="Invoice"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an invoice object.</exception> Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <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);
/// <summary> /// <summary>
/// Extracts the <see cref="PaymentMethod"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, /// 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. /// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary> /// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param> /// <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> /// <param name="expand">Optionally provided to expand the fresh payment method object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="PaymentMethod"/>.</returns> /// <returns>A Stripe <see cref="PaymentMethod"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an payment method object.</exception> Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <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); /// <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> /// <summary>
/// Extracts the <see cref="Subscription"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, /// 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. /// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary> /// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param> /// <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> /// <param name="expand">Optionally provided to expand the fresh subscription object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</returns> /// <returns>A Stripe <see cref="Subscription"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an subscription object.</exception> Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <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);
/// <summary> /// <summary>
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server. /// 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, RequestOptions requestOptions = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<SetupIntent> GetSetupIntent(
string setupIntentId,
SetupIntentGetOptions setupIntentGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<StripeList<Invoice>> ListInvoices( Task<StripeList<Invoice>> ListInvoices(
InvoiceListOptions options = null, InvoiceListOptions options = null,
RequestOptions requestOptions = null, RequestOptions requestOptions = null,

View File

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

View File

@@ -3,27 +3,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Event = Stripe.Event; using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class PaymentSucceededHandler : IPaymentSucceededHandler public class PaymentSucceededHandler(
{
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, ILogger<PaymentSucceededHandler> logger,
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeFacade stripeFacade, IStripeFacade stripeFacade,
@@ -31,35 +17,24 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IUserService userService, IUserService userService,
IPushNotificationService pushNotificationService,
IOrganizationEnableCommand organizationEnableCommand, IOrganizationEnableCommand organizationEnableCommand,
IPricingClient pricingClient) IPricingClient pricingClient,
IPushNotificationAdapter pushNotificationAdapter)
: IPaymentSucceededHandler
{ {
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_providerRepository = providerRepository;
_organizationRepository = organizationRepository;
_stripeEventUtilityService = stripeEventUtilityService;
_userService = userService;
_pushNotificationService = pushNotificationService;
_organizationEnableCommand = organizationEnableCommand;
_pricingClient = pricingClient;
}
/// <summary> /// <summary>
/// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe. /// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
/// </summary> /// </summary>
/// <param name="parsedEvent"></param> /// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent) 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") if (!invoice.Paid || invoice.BillingReason != "subscription_create")
{ {
return; return;
} }
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId);
if (subscription?.Status != StripeSubscriptionStatus.Active) if (subscription?.Status != StripeSubscriptionStatus.Active)
{ {
return; return;
@@ -70,15 +45,15 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
await Task.Delay(5000); await Task.Delay(5000);
} }
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (providerId.HasValue) if (providerId.HasValue)
{ {
var provider = await _providerRepository.GetByIdAsync(providerId.Value); var provider = await providerRepository.GetByIdAsync(providerId.Value);
if (provider == null) if (provider == null)
{ {
_logger.LogError( logger.LogError(
"Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
parsedEvent.Id, parsedEvent.Id,
providerId.Value); providerId.Value);
@@ -86,9 +61,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
return; 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 = var teamsMonthlyLineItem =
subscription.Items.Data.FirstOrDefault(item => subscription.Items.Data.FirstOrDefault(item =>
@@ -100,29 +75,30 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) 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, parsedEvent.Id,
provider.Id); provider.Id);
} }
} }
else if (organizationId.HasValue) else if (organizationId.HasValue)
{ {
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null) if (organization == null)
{ {
return; 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)) if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id))
{ {
return; return;
} }
await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); organization = await organizationRepository.GetByIdAsync(organization.Id);
await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!);
} }
else if (userId.HasValue) else if (userId.HasValue)
{ {
@@ -131,7 +107,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
return; 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,22 +3,7 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class StripeEventProcessor : IStripeEventProcessor public class StripeEventProcessor(
{
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, ILogger<StripeEventProcessor> logger,
ISubscriptionDeletedHandler subscriptionDeletedHandler, ISubscriptionDeletedHandler subscriptionDeletedHandler,
ISubscriptionUpdatedHandler subscriptionUpdatedHandler, ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
@@ -30,61 +15,52 @@ public class StripeEventProcessor : IStripeEventProcessor
IInvoiceCreatedHandler invoiceCreatedHandler, IInvoiceCreatedHandler invoiceCreatedHandler,
IPaymentMethodAttachedHandler paymentMethodAttachedHandler, IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler, ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler) IInvoiceFinalizedHandler invoiceFinalizedHandler,
ISetupIntentSucceededHandler setupIntentSucceededHandler)
: IStripeEventProcessor
{ {
_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) public async Task ProcessEventAsync(Event parsedEvent)
{ {
switch (parsedEvent.Type) switch (parsedEvent.Type)
{ {
case HandledStripeWebhook.SubscriptionDeleted: case HandledStripeWebhook.SubscriptionDeleted:
await _subscriptionDeletedHandler.HandleAsync(parsedEvent); await subscriptionDeletedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.SubscriptionUpdated: case HandledStripeWebhook.SubscriptionUpdated:
await _subscriptionUpdatedHandler.HandleAsync(parsedEvent); await subscriptionUpdatedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.UpcomingInvoice: case HandledStripeWebhook.UpcomingInvoice:
await _upcomingInvoiceHandler.HandleAsync(parsedEvent); await upcomingInvoiceHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.ChargeSucceeded: case HandledStripeWebhook.ChargeSucceeded:
await _chargeSucceededHandler.HandleAsync(parsedEvent); await chargeSucceededHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.ChargeRefunded: case HandledStripeWebhook.ChargeRefunded:
await _chargeRefundedHandler.HandleAsync(parsedEvent); await chargeRefundedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.PaymentSucceeded: case HandledStripeWebhook.PaymentSucceeded:
await _paymentSucceededHandler.HandleAsync(parsedEvent); await paymentSucceededHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.PaymentFailed: case HandledStripeWebhook.PaymentFailed:
await _paymentFailedHandler.HandleAsync(parsedEvent); await paymentFailedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.InvoiceCreated: case HandledStripeWebhook.InvoiceCreated:
await _invoiceCreatedHandler.HandleAsync(parsedEvent); await invoiceCreatedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.PaymentMethodAttached: case HandledStripeWebhook.PaymentMethodAttached:
await _paymentMethodAttachedHandler.HandleAsync(parsedEvent); await paymentMethodAttachedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.CustomerUpdated: case HandledStripeWebhook.CustomerUpdated:
await _customerUpdatedHandler.HandleAsync(parsedEvent); await customerUpdatedHandler.HandleAsync(parsedEvent);
break; break;
case HandledStripeWebhook.InvoiceFinalized: case HandledStripeWebhook.InvoiceFinalized:
await _invoiceFinalizedHandler.HandleAsync(parsedEvent); await invoiceFinalizedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.SetupIntentSucceeded:
await setupIntentSucceededHandler.HandleAsync(parsedEvent);
break; break;
default: default:
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
break; break;
} }
} }

View File

@@ -1,183 +1,122 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Billing.Constants;
#nullable disable using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Billing.Constants; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Stripe; using Stripe;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class StripeEventService : IStripeEventService public class StripeEventService(
{
private readonly GlobalSettings _globalSettings;
private readonly ILogger<StripeEventService> _logger;
private readonly IStripeFacade _stripeFacade;
public StripeEventService(
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILogger<StripeEventService> logger, IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeFacade stripeFacade) IStripeFacade stripeFacade)
: IStripeEventService
{ {
_globalSettings = globalSettings; public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null)
_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) if (!fresh)
{ {
return eventCharge;
}
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 charge;
} }
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null) return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand });
}
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) if (!fresh)
{ {
return eventCustomer;
}
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 customer;
} }
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null) return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand });
}
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) if (!fresh)
{ {
return eventInvoice;
}
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 invoice;
} }
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null) return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand });
}
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) if (!fresh)
{ {
return eventPaymentMethod;
}
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 paymentMethod;
} }
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null) return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
}
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) 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)
{ {
_logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id); var subscription = Extract<Subscription>(stripeEvent);
return eventSubscription;
}
var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand }); if (!fresh)
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 subscription;
} }
return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand });
}
public async Task<bool> ValidateCloudRegion(Event stripeEvent) public async Task<bool> ValidateCloudRegion(Event stripeEvent)
{ {
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; var serverRegion = globalSettings.BaseServiceUri.CloudRegion;
var customerExpansion = new List<string> { "customer" }; var customerExpansion = new List<string> { "customer" };
var customerMetadata = stripeEvent.Type switch var customerMetadata = stripeEvent.Type switch
{ {
HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated => HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated =>
(await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata, (await GetSubscription(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded => HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded =>
(await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata, (await GetCharge(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.UpcomingInvoice => HandledStripeWebhook.UpcomingInvoice =>
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent), await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
(await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.PaymentMethodAttached => HandledStripeWebhook.PaymentMethodAttached =>
(await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata, (await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.CustomerUpdated => HandledStripeWebhook.CustomerUpdated =>
(await GetCustomer(stripeEvent, true))?.Metadata, (await GetCustomer(stripeEvent, true)).Metadata,
HandledStripeWebhook.SetupIntentSucceeded =>
await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
_ => null _ => 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 /* 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' 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. */ 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 invoice = await GetInvoice(localStripeEvent);
var customer = !string.IsNullOrEmpty(invoice.CustomerId) var customer = !string.IsNullOrEmpty(invoice.CustomerId)
? await _stripeFacade.GetCustomer(invoice.CustomerId) ? await stripeFacade.GetCustomer(invoice.CustomerId)
: null; : null;
return customer?.Metadata; 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) private static T Extract<T>(Event stripeEvent)
{ => stripeEvent.Data.Object is not T type
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}'")
throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'"); : type;
}
return type;
}
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata) private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
{ {
const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates;
if (customerMetadata is null)
{
return null;
}
if (customerMetadata.TryGetValue("region", out var value)) if (customerMetadata.TryGetValue("region", out var value))
{ {
return value; return value;
} }
var miscasedRegionKey = customerMetadata.Keys var incorrectlyCasedRegionKey = customerMetadata.Keys
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase)); .FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase));
if (miscasedRegionKey is null) if (incorrectlyCasedRegionKey is null)
{ {
return defaultRegion; return defaultRegion;
} }
_ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue); _ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue);
return !string.IsNullOrWhiteSpace(regionValue) return !string.IsNullOrWhiteSpace(regionValue)
? regionValue ? regionValue

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,8 @@
public interface ISetupIntentCache public interface ISetupIntentCache
{ {
Task<string> Get(Guid subscriberId); Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId);
Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId);
Task Remove(Guid subscriberId); Task RemoveSetupIntentForSubscriber(Guid subscriberId);
Task Set(Guid subscriberId, string setupIntentId); 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 using Microsoft.Extensions.Caching.Distributed;
#nullable disable
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Caches.Implementations; namespace Bit.Core.Billing.Caches.Implementations;
@@ -10,26 +7,41 @@ public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")] [FromKeyedServices("persistent")]
IDistributedCache distributedCache) : ISetupIntentCache 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); 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); await distributedCache.RemoveAsync(cacheKey);
} }
public async Task Set(Guid subscriberId, string setupIntentId) public async Task Set(Guid subscriberId, string setupIntentId)
{ {
var cacheKey = GetCacheKey(subscriberId); var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId);
var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
await distributedCache.SetStringAsync(cacheKey, 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( private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization) Organization organization)
{ {
var setupIntentId = await setupIntentCache.Get(organization.Id); var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id);
if (string.IsNullOrEmpty(setupIntentId)) if (string.IsNullOrEmpty(setupIntentId))
{ {

View File

@@ -383,7 +383,7 @@ public class OrganizationBillingService(
{ {
case PaymentMethodType.BankAccount: case PaymentMethodType.BankAccount:
{ {
await setupIntentCache.Remove(organization.Id); await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id);
break; break;
} }
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): 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 BankName { get; init; }
public required string Last4 { get; init; } public required string Last4 { get; init; }
public required bool Verified { get; init; } public string? HostedVerificationUrl { get; init; }
public string Type => "bankAccount"; public string Type => "bankAccount";
} }
@@ -39,8 +39,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount
{ {
BankName = bankAccount.BankName, BankName = bankAccount.BankName,
Last4 = bankAccount.Last4, Last4 = bankAccount.Last4
Verified = bankAccount.Status == "verified"
}; };
public static MaskedPaymentMethod From(Card card) => new MaskedCard public static MaskedPaymentMethod From(Card card) => new MaskedCard
@@ -61,7 +60,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
{ {
BankName = setupIntent.PaymentMethod.UsBankAccount.BankName, BankName = setupIntent.PaymentMethod.UsBankAccount.BankName,
Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4, Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4,
Verified = false HostedVerificationUrl = setupIntent.NextAction?.VerifyWithMicrodeposits?.HostedVerificationUrl
}; };
public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard 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 public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount
{ {
BankName = bankAccount.BankName, BankName = bankAccount.BankName,
Last4 = bankAccount.Last4, Last4 = bankAccount.Last4
Verified = true
}; };
public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email }; public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email };

View File

@@ -33,6 +33,7 @@ public class GetPaymentMethodQuery(
return null; return null;
} }
// First check for PayPal
if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{ {
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
@@ -47,6 +48,23 @@ public class GetPaymentMethodQuery(
return null; 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 var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null
? customer.InvoiceSettings.DefaultPaymentMethod.Type switch ? customer.InvoiceSettings.DefaultPaymentMethod.Type switch
{ {
@@ -61,40 +79,12 @@ public class GetPaymentMethodQuery(
return paymentMethod; return paymentMethod;
} }
if (customer.DefaultSource != null) return customer.DefaultSource switch
{
paymentMethod = customer.DefaultSource switch
{ {
Card card => MaskedPaymentMethod.From(card), Card card => MaskedPaymentMethod.From(card),
BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount),
Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), Source { Card: not null } source => MaskedPaymentMethod.From(source.Card),
_ => null _ => 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);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -86,3 +86,14 @@ public class OrganizationCollectionManagementPushNotification
public bool LimitCollectionDeletion { get; init; } public bool LimitCollectionDeletion { get; init; }
public bool LimitItemDeletion { 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, 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) Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization)
=> PushAsync(new PushNotification<OrganizationCollectionManagementPushNotification> => PushAsync(new PushNotification<OrganizationCollectionManagementPushNotification>
{ {

View File

@@ -90,4 +90,10 @@ public enum PushType : byte
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
RefreshSecurityTasks = 22, 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)) await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken); .SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken);
break; 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.Notification:
case PushType.NotificationStatus: case PushType.NotificationStatus:
var notificationData = JsonSerializer.Deserialize<PushNotificationData<NotificationPushNotification>>( var notificationData = JsonSerializer.Deserialize<PushNotificationData<NotificationPushNotification>>(
@@ -144,6 +158,7 @@ public static class HubHelpers
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
break; break;
default: default:
logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
break; 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;
using Bit.Billing.Services.Implementations; 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 Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
@@ -11,6 +12,9 @@ namespace Bit.Billing.Test.Services;
public class StripeEventServiceTests public class StripeEventServiceTests
{ {
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderRepository _providerRepository;
private readonly ISetupIntentCache _setupIntentCache;
private readonly IStripeFacade _stripeFacade; private readonly IStripeFacade _stripeFacade;
private readonly StripeEventService _stripeEventService; private readonly StripeEventService _stripeEventService;
@@ -20,8 +24,11 @@ public class StripeEventServiceTests
var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" }; var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" };
globalSettings.BaseServiceUri = baseServiceUriSettings; globalSettings.BaseServiceUri = baseServiceUriSettings;
_organizationRepository = Substitute.For<IOrganizationRepository>();
_providerRepository = Substitute.For<IProviderRepository>();
_setupIntentCache = Substitute.For<ISetupIntentCache>();
_stripeFacade = Substitute.For<IStripeFacade>(); _stripeFacade = Substitute.For<IStripeFacade>();
_stripeEventService = new StripeEventService(globalSettings, Substitute.For<ILogger<StripeEventService>>(), _stripeFacade); _stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade);
} }
#region GetCharge #region GetCharge
@@ -29,50 +36,44 @@ public class StripeEventServiceTests
public async Task GetCharge_EventNotChargeRelated_ThrowsException() public async Task GetCharge_EventNotChargeRelated_ThrowsException()
{ {
// Arrange // Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" });
// Act // Act & Assert
var function = async () => await _stripeEventService.GetCharge(stripeEvent); var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetCharge(stripeEvent));
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<ChargeGetOptions>(), Arg.Any<ChargeGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetCharge_NotFresh_ReturnsEventCharge() public async Task GetCharge_NotFresh_ReturnsEventCharge()
{ {
// Arrange // 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 // Act
var charge = await _stripeEventService.GetCharge(stripeEvent); var charge = await _stripeEventService.GetCharge(stripeEvent);
// Assert // 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( await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<ChargeGetOptions>(), Arg.Any<ChargeGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetCharge_Fresh_Expand_ReturnsAPICharge() public async Task GetCharge_Fresh_Expand_ReturnsAPICharge()
{ {
// Arrange // 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 = new Charge { Id = "ch_test", Amount = 2000 };
var apiCharge = Copy(eventCharge);
var expand = new List<string> { "customer" }; var expand = new List<string> { "customer" };
@@ -90,9 +91,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetCharge( await _stripeFacade.Received().GetCharge(
apiCharge.Id, apiCharge.Id,
Arg.Is<ChargeGetOptions>(options => options.Expand == expand), Arg.Is<ChargeGetOptions>(options => options.Expand == expand));
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
#endregion #endregion
@@ -101,50 +100,44 @@ public class StripeEventServiceTests
public async Task GetCustomer_EventNotCustomerRelated_ThrowsException() public async Task GetCustomer_EventNotCustomerRelated_ThrowsException()
{ {
// Arrange // Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" });
// Act // Act & Assert
var function = async () => await _stripeEventService.GetCustomer(stripeEvent); var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetCustomer(stripeEvent));
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<CustomerGetOptions>(), Arg.Any<CustomerGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetCustomer_NotFresh_ReturnsEventCustomer() public async Task GetCustomer_NotFresh_ReturnsEventCustomer()
{ {
// Arrange // 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 // Act
var customer = await _stripeEventService.GetCustomer(stripeEvent); var customer = await _stripeEventService.GetCustomer(stripeEvent);
// Assert // 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( await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<CustomerGetOptions>(), Arg.Any<CustomerGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer() public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer()
{ {
// Arrange // 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 = new Customer { Id = "cus_test", Email = "updated@example.com" };
var apiCustomer = Copy(eventCustomer);
var expand = new List<string> { "subscriptions" }; var expand = new List<string> { "subscriptions" };
@@ -162,9 +155,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetCustomer( await _stripeFacade.Received().GetCustomer(
apiCustomer.Id, apiCustomer.Id,
Arg.Is<CustomerGetOptions>(options => options.Expand == expand), Arg.Is<CustomerGetOptions>(options => options.Expand == expand));
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
#endregion #endregion
@@ -173,50 +164,44 @@ public class StripeEventServiceTests
public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException() public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException()
{ {
// Arrange // Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act // Act & Assert
var function = async () => await _stripeEventService.GetInvoice(stripeEvent); var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetInvoice(stripeEvent));
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<InvoiceGetOptions>(), Arg.Any<InvoiceGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetInvoice_NotFresh_ReturnsEventInvoice() public async Task GetInvoice_NotFresh_ReturnsEventInvoice()
{ {
// Arrange // 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 // Act
var invoice = await _stripeEventService.GetInvoice(stripeEvent); var invoice = await _stripeEventService.GetInvoice(stripeEvent);
// Assert // 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( await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<InvoiceGetOptions>(), Arg.Any<InvoiceGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice() public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice()
{ {
// Arrange // 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 = new Invoice { Id = "in_test", AmountDue = 2000 };
var apiInvoice = Copy(eventInvoice);
var expand = new List<string> { "customer" }; var expand = new List<string> { "customer" };
@@ -234,9 +219,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetInvoice( await _stripeFacade.Received().GetInvoice(
apiInvoice.Id, apiInvoice.Id,
Arg.Is<InvoiceGetOptions>(options => options.Expand == expand), Arg.Is<InvoiceGetOptions>(options => options.Expand == expand));
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
#endregion #endregion
@@ -245,50 +228,44 @@ public class StripeEventServiceTests
public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException() public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException()
{ {
// Arrange // Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act // Act & Assert
var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent); var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetPaymentMethod(stripeEvent));
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<PaymentMethodGetOptions>(), Arg.Any<PaymentMethodGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod() public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod()
{ {
// Arrange // 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 // Act
var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent); var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent);
// Assert // 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( await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<PaymentMethodGetOptions>(), Arg.Any<PaymentMethodGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod() public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod()
{ {
// Arrange // 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 = new PaymentMethod { Id = "pm_test", Type = "card" };
var apiPaymentMethod = Copy(eventPaymentMethod);
var expand = new List<string> { "customer" }; var expand = new List<string> { "customer" };
@@ -306,9 +283,7 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetPaymentMethod( await _stripeFacade.Received().GetPaymentMethod(
apiPaymentMethod.Id, apiPaymentMethod.Id,
Arg.Is<PaymentMethodGetOptions>(options => options.Expand == expand), Arg.Is<PaymentMethodGetOptions>(options => options.Expand == expand));
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
#endregion #endregion
@@ -317,50 +292,44 @@ public class StripeEventServiceTests
public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException() public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException()
{ {
// Arrange // Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" });
// Act // Act & Assert
var function = async () => await _stripeEventService.GetSubscription(stripeEvent); var exception = await Assert.ThrowsAsync<Exception>(async () => await _stripeEventService.GetSubscription(stripeEvent));
// Assert
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>(), Arg.Any<SubscriptionGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetSubscription_NotFresh_ReturnsEventSubscription() public async Task GetSubscription_NotFresh_ReturnsEventSubscription()
{ {
// Arrange // 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 // Act
var subscription = await _stripeEventService.GetSubscription(stripeEvent); var subscription = await _stripeEventService.GetSubscription(stripeEvent);
// Assert // 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( await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>(), Arg.Any<SubscriptionGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription() public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription()
{ {
// Arrange // 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 = new Subscription { Id = "sub_test", Status = "canceled" };
var apiSubscription = Copy(eventSubscription);
var expand = new List<string> { "customer" }; var expand = new List<string> { "customer" };
@@ -378,9 +347,71 @@ public class StripeEventServiceTests
await _stripeFacade.Received().GetSubscription( await _stripeFacade.Received().GetSubscription(
apiSubscription.Id, apiSubscription.Id,
Arg.Is<SubscriptionGetOptions>(options => options.Expand == expand), Arg.Is<SubscriptionGetOptions>(options => options.Expand == expand));
Arg.Any<RequestOptions>(), }
Arg.Any<CancellationToken>()); #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 #endregion
@@ -389,18 +420,16 @@ public class StripeEventServiceTests
public async Task ValidateCloudRegion_SubscriptionUpdated_Success() public async Task ValidateCloudRegion_SubscriptionUpdated_Success()
{ {
// Arrange // 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 = CreateMockCustomer();
mockSubscription.Customer = customer;
var customer = await GetCustomerAsync();
subscription.Customer = customer;
_stripeFacade.GetSubscription( _stripeFacade.GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>()) Arg.Any<SubscriptionGetOptions>())
.Returns(subscription); .Returns(mockSubscription);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -409,28 +438,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription( await _stripeFacade.Received(1).GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>(), Arg.Any<SubscriptionGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_ChargeSucceeded_Success() public async Task ValidateCloudRegion_ChargeSucceeded_Success()
{ {
// Arrange // 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 = CreateMockCustomer();
mockCharge.Customer = customer;
var customer = await GetCustomerAsync();
charge.Customer = customer;
_stripeFacade.GetCharge( _stripeFacade.GetCharge(
charge.Id, mockCharge.Id,
Arg.Any<ChargeGetOptions>()) Arg.Any<ChargeGetOptions>())
.Returns(charge); .Returns(mockCharge);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -439,24 +464,21 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCharge( await _stripeFacade.Received(1).GetCharge(
charge.Id, mockCharge.Id,
Arg.Any<ChargeGetOptions>(), Arg.Any<ChargeGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_UpcomingInvoice_Success() public async Task ValidateCloudRegion_UpcomingInvoice_Success()
{ {
// Arrange // 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 = CreateMockCustomer();
var customer = await GetCustomerAsync();
_stripeFacade.GetCustomer( _stripeFacade.GetCustomer(
invoice.CustomerId, mockInvoice.CustomerId,
Arg.Any<CustomerGetOptions>()) Arg.Any<CustomerGetOptions>())
.Returns(customer); .Returns(customer);
@@ -467,28 +489,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCustomer( await _stripeFacade.Received(1).GetCustomer(
invoice.CustomerId, mockInvoice.CustomerId,
Arg.Any<CustomerGetOptions>(), Arg.Any<CustomerGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_InvoiceCreated_Success() public async Task ValidateCloudRegion_InvoiceCreated_Success()
{ {
// Arrange // 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 = CreateMockCustomer();
mockInvoice.Customer = customer;
var customer = await GetCustomerAsync();
invoice.Customer = customer;
_stripeFacade.GetInvoice( _stripeFacade.GetInvoice(
invoice.Id, mockInvoice.Id,
Arg.Any<InvoiceGetOptions>()) Arg.Any<InvoiceGetOptions>())
.Returns(invoice); .Returns(mockInvoice);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -497,28 +515,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetInvoice( await _stripeFacade.Received(1).GetInvoice(
invoice.Id, mockInvoice.Id,
Arg.Any<InvoiceGetOptions>(), Arg.Any<InvoiceGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_PaymentMethodAttached_Success() public async Task ValidateCloudRegion_PaymentMethodAttached_Success()
{ {
// Arrange // 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 = CreateMockCustomer();
mockPaymentMethod.Customer = customer;
var customer = await GetCustomerAsync();
paymentMethod.Customer = customer;
_stripeFacade.GetPaymentMethod( _stripeFacade.GetPaymentMethod(
paymentMethod.Id, mockPaymentMethod.Id,
Arg.Any<PaymentMethodGetOptions>()) Arg.Any<PaymentMethodGetOptions>())
.Returns(paymentMethod); .Returns(mockPaymentMethod);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -527,24 +541,21 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetPaymentMethod( await _stripeFacade.Received(1).GetPaymentMethod(
paymentMethod.Id, mockPaymentMethod.Id,
Arg.Any<PaymentMethodGetOptions>(), Arg.Any<PaymentMethodGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_CustomerUpdated_Success() public async Task ValidateCloudRegion_CustomerUpdated_Success()
{ {
// Arrange // Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); var mockCustomer = CreateMockCustomer();
var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer);
var customer = Copy(stripeEvent.Data.Object as Customer);
_stripeFacade.GetCustomer( _stripeFacade.GetCustomer(
customer.Id, mockCustomer.Id,
Arg.Any<CustomerGetOptions>()) Arg.Any<CustomerGetOptions>())
.Returns(customer); .Returns(mockCustomer);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -553,29 +564,24 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCustomer( await _stripeFacade.Received(1).GetCustomer(
customer.Id, mockCustomer.Id,
Arg.Any<CustomerGetOptions>(), Arg.Any<CustomerGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse() public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse()
{ {
// Arrange // 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 = new Customer { Id = "cus_test", Metadata = null };
mockSubscription.Customer = customer;
var customer = await GetCustomerAsync();
customer.Metadata = null;
subscription.Customer = customer;
_stripeFacade.GetSubscription( _stripeFacade.GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>()) Arg.Any<SubscriptionGetOptions>())
.Returns(subscription); .Returns(mockSubscription);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -584,29 +590,24 @@ public class StripeEventServiceTests
Assert.False(cloudRegionValid); Assert.False(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription( await _stripeFacade.Received(1).GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>(), Arg.Any<SubscriptionGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue() public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue()
{ {
// Arrange // 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 = new Customer { Id = "cus_test", Metadata = new Dictionary<string, string>() };
mockSubscription.Customer = customer;
var customer = await GetCustomerAsync();
customer.Metadata = new Dictionary<string, string>();
subscription.Customer = customer;
_stripeFacade.GetSubscription( _stripeFacade.GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>()) Arg.Any<SubscriptionGetOptions>())
.Returns(subscription); .Returns(mockSubscription);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -615,32 +616,28 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription( await _stripeFacade.Received(1).GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>(), Arg.Any<SubscriptionGetOptions>());
Arg.Any<RequestOptions>(),
Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue() public async Task ValidateCloudRegion_MetadataIncorrectlyCasedRegion_ReturnsTrue()
{ {
// Arrange // 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 = new Customer
var customer = await GetCustomerAsync();
customer.Metadata = new Dictionary<string, string>
{ {
{ "Region", "US" } Id = "cus_test",
Metadata = new Dictionary<string, string> { { "Region", "US" } }
}; };
mockSubscription.Customer = customer;
subscription.Customer = customer;
_stripeFacade.GetSubscription( _stripeFacade.GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>()) Arg.Any<SubscriptionGetOptions>())
.Returns(subscription); .Returns(mockSubscription);
// Act // Act
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
@@ -649,31 +646,209 @@ public class StripeEventServiceTests
Assert.True(cloudRegionValid); Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription( await _stripeFacade.Received(1).GetSubscription(
subscription.Id, mockSubscription.Id,
Arg.Any<SubscriptionGetOptions>(), Arg.Any<SubscriptionGetOptions>());
Arg.Any<RequestOptions>(), }
Arg.Any<CancellationToken>());
[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 #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)); return new Event
var properties = input.GetType().GetProperties();
foreach (var property in properties)
{ {
var value = property.GetValue(input); Id = id,
copy! Type = type,
.GetType() Data = new EventData
.GetProperty(property.Name)! {
.SetValue(copy, value); Object = (IHasObject)dataObject
}
};
} }
return copy; private static Customer CreateMockCustomer()
{
return new Customer
{
Id = "cus_test",
Metadata = new Dictionary<string, string> { { "region", "US" } }
};
} }
private static async Task<Customer> GetCustomerAsync()
=> (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer;
} }

View File

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

View File

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

View File

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

View File

@@ -108,7 +108,7 @@ public class GetPaymentMethodQueryTests
var maskedBankAccount = maskedPaymentMethod.AsT0; var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4); Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified); Assert.Null(maskedBankAccount.HostedVerificationUrl);
} }
[Fact] [Fact]
@@ -142,7 +142,7 @@ public class GetPaymentMethodQueryTests
var maskedBankAccount = maskedPaymentMethod.AsT0; var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4); Assert.Equal("9999", maskedBankAccount.Last4);
Assert.True(maskedBankAccount.Verified); Assert.Null(maskedBankAccount.HostedVerificationUrl);
} }
[Fact] [Fact]
@@ -163,7 +163,7 @@ public class GetPaymentMethodQueryTests
Arg.Is<CustomerGetOptions>(options => Arg.Is<CustomerGetOptions>(options =>
options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); 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 _stripeAdapter
.SetupIntentGet("seti_123", .SetupIntentGet("seti_123",
@@ -177,7 +177,10 @@ public class GetPaymentMethodQueryTests
}, },
NextAction = new SetupIntentNextAction NextAction = new SetupIntentNextAction
{ {
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits
{
HostedVerificationUrl = "https://example.com"
}
}, },
Status = "requires_action" Status = "requires_action"
}); });
@@ -189,7 +192,7 @@ public class GetPaymentMethodQueryTests
var maskedBankAccount = maskedPaymentMethod.AsT0; var maskedBankAccount = maskedPaymentMethod.AsT0;
Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("Chase", maskedBankAccount.BankName);
Assert.Equal("9999", maskedBankAccount.Last4); Assert.Equal("9999", maskedBankAccount.Last4);
Assert.False(maskedBankAccount.Verified); Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl);
} }
[Fact] [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, sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntent.Id,
Arg.Is<SetupIntentGetOptions>(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); Arg.Is<SetupIntentGetOptions>(options => options.Expand.Contains("payment_method"))).Returns(setupIntent);
@@ -1876,7 +1876,7 @@ public class SubscriberServiceTests
PaymentMethodId = "payment_method_id" 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>(); 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] [Fact]
public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() 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] [Fact]
public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse()
{ {