1
0
mirror of https://github.com/bitwarden/server synced 2025-12-28 22:23:30 +00:00

[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
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();
}
}