[PM-24067] Check for unverified bank account in free trial / inactive subscription warning (#6117)

* [NO LOGIC] Move query to core

* Check for unverified bank account in free trial and inactive subscription warnings

* Run dotnet format

* fix test

* Run dotnet format

* Remove errant file
This commit is contained in:
Alex Morask 2025-07-24 09:59:23 -05:00 committed by GitHub
parent 988b994624
commit 2d1f914eae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 200 additions and 99 deletions

View File

@ -3,10 +3,10 @@ using System.Diagnostics;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses;
using Bit.Api.Billing.Queries.Organizations;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
@ -28,7 +28,7 @@ public class OrganizationBillingController(
ICurrentContext currentContext,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IOrganizationWarningsQuery organizationWarningsQuery,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IPaymentService paymentService,
IPricingClient pricingClient,
ISubscriberService subscriberService,
@ -363,7 +363,7 @@ public class OrganizationBillingController(
public async Task<IResult> GetWarningsAsync([FromRoute] Guid organizationId)
{
/*
* We'll keep these available at the User level, because we're hiding any pertinent information and
* We'll keep these available at the User level because we're hiding any pertinent information, and
* we want to throw as few errors as possible since these are not core features.
*/
if (!await currentContext.OrganizationUser(organizationId))
@ -378,9 +378,9 @@ public class OrganizationBillingController(
return Error.NotFound();
}
var response = await organizationWarningsQuery.Run(organization);
var warnings = await getOrganizationWarningsQuery.Run(organization);
return TypedResults.Ok(response);
return TypedResults.Ok(warnings);
}

View File

@ -1,11 +0,0 @@
using Bit.Api.Billing.Queries.Organizations;
namespace Bit.Api.Billing;
public static class Registrations
{
public static void AddBillingQueries(this IServiceCollection services)
{
services.AddTransient<IOrganizationWarningsQuery, OrganizationWarningsQuery>();
}
}

View File

@ -27,7 +27,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.Billing;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Tools.ImportFeatures;
@ -184,7 +183,6 @@ public class Startup
services.AddImportServices();
services.AddPhishingDomainServices(globalSettings);
services.AddBillingQueries();
services.AddSendServices();
// Authorization Handlers

View File

@ -33,6 +33,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();
services.AddPaymentOperations();
services.AddOrganizationLicenseCommandsQueries();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
}
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)

View File

@ -1,7 +1,6 @@
#nullable enable
namespace Bit.Api.Billing.Models.Responses.Organizations;
namespace Bit.Core.Billing.Organizations.Models;
public record OrganizationWarningsResponse
public record OrganizationWarnings
{
public FreeTrialWarning? FreeTrial { get; set; }
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }

View File

@ -1,42 +1,44 @@
// ReSharper disable InconsistentNaming
#nullable enable
using Bit.Api.Billing.Models.Responses.Organizations;
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.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Stripe;
using static Bit.Core.Billing.Utilities;
using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning;
using FreeTrialWarning = Bit.Core.Billing.Organizations.Models.OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning =
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning;
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.InactiveSubscriptionWarning;
using ResellerRenewalWarning =
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning;
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.ResellerRenewalWarning;
namespace Bit.Api.Billing.Queries.Organizations;
namespace Bit.Core.Billing.Organizations.Queries;
public interface IOrganizationWarningsQuery
using static StripeConstants;
public interface IGetOrganizationWarningsQuery
{
Task<OrganizationWarningsResponse> Run(
Task<OrganizationWarnings> Run(
Organization organization);
}
public class OrganizationWarningsQuery(
public class GetOrganizationWarningsQuery(
ICurrentContext currentContext,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationWarningsQuery
ISubscriberService subscriberService) : IGetOrganizationWarningsQuery
{
public async Task<OrganizationWarningsResponse> Run(
public async Task<OrganizationWarnings> Run(
Organization organization)
{
var response = new OrganizationWarningsResponse();
var response = new OrganizationWarnings();
var subscription =
await subscriberService.GetSubscription(organization,
@ -69,7 +71,7 @@ public class OrganizationWarningsQuery(
if (subscription is not
{
Status: StripeConstants.SubscriptionStatus.Trialing,
Status: SubscriptionStatus.Trialing,
TrialEnd: not null,
Customer: not null
})
@ -79,10 +81,13 @@ public class OrganizationWarningsQuery(
var customer = subscription.Customer;
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
var hasPaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId);
hasUnverifiedBankAccount ||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
if (hasPaymentMethod)
{
@ -101,49 +106,58 @@ public class OrganizationWarningsQuery(
Provider? provider,
Subscription subscription)
{
if (organization.Enabled && subscription.Status is StripeConstants.SubscriptionStatus.Trialing)
{
var isStripeCustomerWithoutPayment =
subscription.Customer.InvoiceSettings.DefaultPaymentMethodId is null;
var isBraintreeCustomer =
subscription.Customer.Metadata.ContainsKey(BraintreeCustomerIdKey);
var hasNoPaymentMethod = isStripeCustomerWithoutPayment && !isBraintreeCustomer;
var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id);
if (hasNoPaymentMethod && await currentContext.OrganizationOwner(organization.Id))
{
return new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" };
}
}
if (organization.Enabled ||
subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid
and not StripeConstants.SubscriptionStatus.Canceled)
switch (organization.Enabled)
{
return null;
}
if (provider != null)
{
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
}
if (await currentContext.OrganizationOwner(organization.Id))
{
return subscription.Status switch
{
StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
// Member of an enabled, trialing organization.
case true when subscription.Status is SubscriptionStatus.Trialing:
{
Resolution = "add_payment_method"
},
StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
{
Resolution = "resubscribe"
},
_ => null
};
}
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
var hasPaymentMethod =
!string.IsNullOrEmpty(subscription.Customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(subscription.Customer.DefaultSourceId) ||
hasUnverifiedBankAccount ||
subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
// If this member is the owner and there's no payment method on file, ask them to add one.
return isOrganizationOwner && !hasPaymentMethod
? new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" }
: null;
}
// Member of disabled and unpaid or canceled organization.
case false when subscription.Status is SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled:
{
// If the organization is managed by a provider, return a warning asking them to contact the provider.
if (provider != null)
{
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
}
/* If the organization is not managed by a provider and this user is the owner, return an action warning based
on the subscription status. */
if (isOrganizationOwner)
{
return subscription.Status switch
{
SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
{
Resolution = "add_payment_method"
},
SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
{
Resolution = "resubscribe"
},
_ => null
};
}
// Otherwise, this member is not the owner, and we need to ask them to contact the owner.
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
}
default: return null;
}
}
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
@ -158,7 +172,7 @@ public class OrganizationWarningsQuery(
return null;
}
if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice)
if (subscription.CollectionMethod != CollectionMethod.SendInvoice)
{
return null;
}
@ -168,8 +182,8 @@ public class OrganizationWarningsQuery(
// ReSharper disable once ConvertIfStatementToSwitchStatement
if (subscription is
{
Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active,
LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid }
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
LatestInvoice: null or { Status: InvoiceStatus.Paid }
} && (subscription.CurrentPeriodEnd - now).TotalDays <= 14)
{
return new ResellerRenewalWarning
@ -184,8 +198,8 @@ public class OrganizationWarningsQuery(
if (subscription is
{
Status: StripeConstants.SubscriptionStatus.Active,
LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null }
Status: SubscriptionStatus.Active,
LatestInvoice: { Status: InvoiceStatus.Open, DueDate: not null }
} && subscription.LatestInvoice.DueDate > now)
{
return new ResellerRenewalWarning
@ -200,7 +214,7 @@ public class OrganizationWarningsQuery(
}
// ReSharper disable once InvertIf
if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue)
if (subscription.Status == SubscriptionStatus.PastDue)
{
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
{
@ -226,4 +240,22 @@ public class OrganizationWarningsQuery(
return null;
}
private async Task<bool> HasUnverifiedBankAccount(
Organization organization)
{
var setupIntentId = await setupIntentCache.Get(organization.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return false;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
return setupIntent.IsUnverifiedBankAccount();
}
}

View File

@ -14,7 +14,7 @@ using NSubstitute;
using Xunit;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Bit.Core.Test.Billing.OrganizationFeatures.OrganizationLicenses;
namespace Bit.Core.Test.Billing.Organizations.Commands;
[SutProviderCustomize]
public class UpdateOrganizationLicenseCommandTests

View File

@ -18,7 +18,7 @@ using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.OrganizationFeatures.OrganizationLicenses;
namespace Bit.Core.Test.Billing.Organizations.Queries;
[SubscriptionInfoCustomize]
[OrganizationLicenseCustomize]

View File

@ -1,12 +1,13 @@
using Bit.Api.Billing.Queries.Organizations;
using Bit.Core.AdminConsole.Entities;
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.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@ -15,17 +16,17 @@ using Stripe;
using Stripe.TestHelpers;
using Xunit;
namespace Bit.Api.Test.Billing.Queries.Organizations;
namespace Bit.Core.Test.Billing.Organizations.Queries;
[SutProviderCustomize]
public class OrganizationWarningsQueryTests
public class GetOrganizationWarningsQueryTests
{
private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"];
[Theory, BitAutoData]
public async Task Run_NoSubscription_NoWarnings(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
@ -46,7 +47,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_FreeTrialWarning(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;
@ -70,6 +71,7 @@ public class OrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().Get(organization.Id).Returns((string?)null);
var response = await sutProvider.Sut.Run(organization);
@ -79,10 +81,90 @@ public class OrganizationWarningsQueryTests
});
}
[Theory, BitAutoData]
public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;
const string setupIntentId = "setup_intent_id";
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Trialing,
TrialEnd = now.AddDays(7),
Customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
},
TestClock = new TestClock
{
FrozenTime = now
}
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().Get(organization.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(
options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.FreeTrial);
}
[Theory, BitAutoData]
public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethodOptionalTrial(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Trialing,
Customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
});
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().Get(organization.Id).Returns((string?)null);
var response = await sutProvider.Sut.Run(organization);
Assert.True(response is
{
InactiveSubscription.Resolution: "add_payment_method_optional_trial"
});
}
[Theory, BitAutoData]
public async Task Run_Has_InactiveSubscriptionWarning_ContactProvider(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.Enabled = false;
@ -109,7 +191,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethod(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.Enabled = false;
@ -135,7 +217,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_InactiveSubscriptionWarning_Resubscribe(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.Enabled = false;
@ -161,7 +243,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_InactiveSubscriptionWarning_ContactOwner(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.Enabled = false;
@ -187,7 +269,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_ResellerRenewalWarning_Upcoming(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;
@ -225,7 +307,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_ResellerRenewalWarning_Issued(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;
@ -269,7 +351,7 @@ public class OrganizationWarningsQueryTests
[Theory, BitAutoData]
public async Task Run_Has_ResellerRenewalWarning_PastDue(
Organization organization,
SutProvider<OrganizationWarningsQuery> sutProvider)
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;

View File

@ -12,7 +12,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using Xunit;
namespace Bit.Core.Test.Billing.OrganizationFeatures.OrganizationLicenses;
namespace Bit.Core.Test.Billing.Organizations.Queries;
[SutProviderCustomize]
public class GetSelfHostedOrganizationLicenseQueryTests