mirror of
https://github.com/bitwarden/server
synced 2025-12-26 21:23:39 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user