1
0
mirror of https://github.com/bitwarden/server synced 2026-01-04 09:33:40 +00:00

[PM-22145] Tax ID notifications for Organizations and Providers (#6185)

* Add TaxRegistrationsListAsync to StripeAdapter

* Update GetOrganizationWarningsQuery, add GetProviderWarningsQuery to support tax ID warning

* Add feature flag to control web display

* Run dotnet format'
This commit is contained in:
Alex Morask
2025-08-18 09:42:51 -05:00
committed by GitHub
parent 8a36d96e56
commit bd133b936c
19 changed files with 821 additions and 105 deletions

View File

@@ -6,7 +6,6 @@ using Bit.Api.Billing.Models.Responses;
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 +27,6 @@ public class OrganizationBillingController(
ICurrentContext currentContext,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IPaymentService paymentService,
IPricingClient pricingClient,
ISubscriberService subscriberService,
@@ -359,31 +357,6 @@ public class OrganizationBillingController(
return TypedResults.Ok(providerId);
}
[HttpGet("warnings")]
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 want to throw as few errors as possible since these are not core features.
*/
if (!await currentContext.OrganizationUser(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var warnings = await getOrganizationWarningsQuery.Run(organization);
return TypedResults.Ok(warnings);
}
[HttpPost("change-frequency")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(

View File

@@ -1,9 +1,10 @@
#nullable enable
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requirements;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Utilities;
@@ -21,6 +22,7 @@ public class OrganizationBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
@@ -104,4 +106,14 @@ public class OrganizationBillingVNextController(
var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode);
return Handle(result);
}
[Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")]
[InjectOrganization]
public async Task<IResult> GetWarningsAsync(
[BindNever] Organization organization)
{
var warnings = await getOrganizationWarningsQuery.Run(organization);
return TypedResults.Ok(warnings);
}
}

View File

@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -19,6 +20,7 @@ public class ProviderBillingVNextController(
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetProviderWarningsQuery getProviderWarningsQuery,
IProviderService providerService,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
@@ -104,4 +106,13 @@ public class ProviderBillingVNextController(
var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode);
return Handle(result);
}
[HttpGet("warnings")]
[InjectProvider(ProviderUserType.ServiceUser)]
public async Task<IResult> GetWarningsAsync(
[BindNever] Provider provider)
{
var warnings = await getProviderWarningsQuery.Run(provider);
return TypedResults.Ok(warnings);
}
}

View File

@@ -1,5 +1,4 @@
#nullable enable
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization;
using Bit.Core.Context;
using Bit.Core.Enums;

View File

@@ -113,6 +113,21 @@ public static class StripeConstants
public const string SpanishNIF = "es_cif";
}
public static class TaxIdVerificationStatus
{
public const string Pending = "pending";
public const string Unavailable = "unavailable";
public const string Unverified = "unverified";
public const string Verified = "verified";
}
public static class TaxRegistrationStatus
{
public const string Active = "active";
public const string Expired = "expired";
public const string Scheduled = "scheduled";
}
public static class ValidateTaxLocationTiming
{
public const string Deferred = "deferred";

View File

@@ -5,6 +5,7 @@ public record OrganizationWarnings
public FreeTrialWarning? FreeTrial { get; set; }
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
public ResellerRenewalWarning? ResellerRenewal { get; set; }
public TaxIdWarning? TaxId { get; set; }
public record FreeTrialWarning
{
@@ -39,4 +40,9 @@ public record OrganizationWarnings
public required DateTime SuspensionDate { get; set; }
}
}
public record TaxIdWarning
{
public required string Type { get; set; }
}
}

View File

@@ -1,26 +1,25 @@
// ReSharper disable InconsistentNaming
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.Enums;
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 FreeTrialWarning = Bit.Core.Billing.Organizations.Models.OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning =
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.InactiveSubscriptionWarning;
using ResellerRenewalWarning =
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.ResellerRenewalWarning;
using Stripe.Tax;
namespace Bit.Core.Billing.Organizations.Queries;
using static StripeConstants;
using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning;
using ResellerRenewalWarning = OrganizationWarnings.ResellerRenewalWarning;
using TaxIdWarning = OrganizationWarnings.TaxIdWarning;
public interface IGetOrganizationWarningsQuery
{
@@ -38,29 +37,31 @@ public class GetOrganizationWarningsQuery(
public async Task<OrganizationWarnings> Run(
Organization organization)
{
var response = new OrganizationWarnings();
var warnings = new OrganizationWarnings();
var subscription =
await subscriberService.GetSubscription(organization,
new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] });
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] });
if (subscription == null)
{
return response;
return warnings;
}
response.FreeTrial = await GetFreeTrialWarning(organization, subscription);
warnings.FreeTrial = await GetFreeTrialWarningAsync(organization, subscription);
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription);
warnings.InactiveSubscription = await GetInactiveSubscriptionWarningAsync(organization, provider, subscription);
response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription);
warnings.ResellerRenewal = await GetResellerRenewalWarningAsync(provider, subscription);
return response;
warnings.TaxId = await GetTaxIdWarningAsync(organization, subscription.Customer, provider);
return warnings;
}
private async Task<FreeTrialWarning?> GetFreeTrialWarning(
private async Task<FreeTrialWarning?> GetFreeTrialWarningAsync(
Organization organization,
Subscription subscription)
{
@@ -81,7 +82,7 @@ public class GetOrganizationWarningsQuery(
var customer = subscription.Customer;
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization);
var hasPaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
@@ -101,66 +102,51 @@ public class GetOrganizationWarningsQuery(
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
}
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarning(
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarningAsync(
Organization organization,
Provider? provider,
Subscription subscription)
{
// If the organization is enabled or the subscription is active, don't return a warning.
if (organization.Enabled || subscription is not
{
Status: SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled
})
{
return null;
}
// 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" };
}
var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id);
switch (organization.Enabled)
/* If the organization is not managed by a provider and this user is the owner, return a warning based
on the subscription status. */
if (isOrganizationOwner)
{
// Member of an enabled, trialing organization.
case true when subscription.Status is SubscriptionStatus.Trialing:
return subscription.Status switch
{
SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
{
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
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:
Resolution = "add_payment_method"
},
SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
{
// 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;
Resolution = "resubscribe"
},
_ => null
};
}
// Otherwise, return a warning asking them to contact the owner.
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
}
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarningAsync(
Provider? provider,
Subscription subscription)
{
@@ -241,7 +227,62 @@ public class GetOrganizationWarningsQuery(
return null;
}
private async Task<bool> HasUnverifiedBankAccount(
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
Organization organization,
Customer customer,
Provider? provider)
{
var productTier = organization.PlanType.GetProductTier();
// Only business tier customers can have tax IDs
if (productTier is not ProductTierType.Teams and not ProductTierType.Enterprise)
{
return null;
}
// Only an organization owner can update a tax ID
if (!await currentContext.OrganizationOwner(organization.Id))
{
return null;
}
if (provider != null)
{
return null;
}
// Get active and scheduled registrations
var registrations = (await Task.WhenAll(
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country);
// If we're not registered in their country, we don't need a warning
if (registration == null)
{
return null;
}
var taxId = customer.TaxIds.FirstOrDefault();
return taxId switch
{
// Customer's tax ID is missing
null => new TaxIdWarning { Type = "tax_id_missing" },
// Not sure if this case is valid, but Stripe says this property is nullable
not null when taxId.Verification == null => null,
// Customer's tax ID is pending verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
// Customer's tax ID failed verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
_ => null
};
}
private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization)
{
var setupIntentId = await setupIntentCache.Get(organization.Id);

View File

@@ -0,0 +1,18 @@
namespace Bit.Core.Billing.Providers.Models;
public class ProviderWarnings
{
public SuspensionWarning? Suspension { get; set; }
public TaxIdWarning? TaxId { get; set; }
public record SuspensionWarning
{
public required string Resolution { get; set; }
public DateTime? SubscriptionCancelsAt { get; set; }
}
public record TaxIdWarning
{
public required string Type { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Providers.Models;
namespace Bit.Core.Billing.Providers.Queries;
public interface IGetProviderWarningsQuery
{
Task<ProviderWarnings?> Run(Provider provider);
}

View File

@@ -161,6 +161,7 @@ public static class FeatureFlagKeys
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
/* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";

View File

@@ -48,6 +48,7 @@ public interface IStripeAdapter
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);
Task<Stripe.TaxId> TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null);
Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null);
Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options);
Task<Stripe.Refund> RefundCreateAsync(Stripe.RefundCreateOptions options);
Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null);

View File

@@ -22,6 +22,7 @@ public class StripeAdapter : IStripeAdapter
private readonly Stripe.SetupIntentService _setupIntentService;
private readonly Stripe.TestHelpers.TestClockService _testClockService;
private readonly CustomerBalanceTransactionService _customerBalanceTransactionService;
private readonly Stripe.Tax.RegistrationService _taxRegistrationService;
public StripeAdapter()
{
@@ -39,6 +40,7 @@ public class StripeAdapter : IStripeAdapter
_setupIntentService = new SetupIntentService();
_testClockService = new Stripe.TestHelpers.TestClockService();
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
_taxRegistrationService = new Stripe.Tax.RegistrationService();
}
public Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions options)
@@ -208,6 +210,11 @@ public class StripeAdapter : IStripeAdapter
return _taxIdService.DeleteAsync(customerId, taxIdId);
}
public Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null)
{
return _taxRegistrationService.ListAsync(options);
}
public Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options)
{
return _chargeService.ListAsync(options);