1
0
mirror of https://github.com/bitwarden/server.git synced 2025-03-12 13:29:14 +01:00

[PM-18578] Don't enable automatic tax for non-taxable non-US businesses during invoice.upcoming (#5443)

* Only enable automatic tax for US subscriptions or EU subscriptions that are taxable.

* Run dotnet format
This commit is contained in:
Alex Morask 2025-02-25 13:36:12 -05:00 committed by GitHub
parent 66feebd358
commit 622ef902ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 113 additions and 112 deletions

View File

@ -1,6 +1,7 @@
using Bit.Billing.Constants; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -11,94 +12,66 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler : IUpcomingInvoiceHandler public class UpcomingInvoiceHandler(
{
private readonly ILogger<StripeEventProcessor> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly IUserService _userService;
private readonly IStripeFacade _stripeFacade;
private readonly IMailService _mailService;
private readonly IProviderRepository _providerRepository;
private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
private readonly IOrganizationRepository _organizationRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public UpcomingInvoiceHandler(
ILogger<StripeEventProcessor> logger, ILogger<StripeEventProcessor> logger,
IStripeEventService stripeEventService,
IUserService userService,
IStripeFacade stripeFacade,
IMailService mailService, IMailService mailService,
IProviderRepository providerRepository,
IValidateSponsorshipCommand validateSponsorshipCommand,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService) IProviderRepository providerRepository,
IStripeFacade stripeFacade,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand)
: IUpcomingInvoiceHandler
{ {
_logger = logger;
_stripeEventService = stripeEventService;
_userService = userService;
_stripeFacade = stripeFacade;
_mailService = mailService;
_providerRepository = providerRepository;
_validateSponsorshipCommand = validateSponsorshipCommand;
_organizationRepository = organizationRepository;
_stripeEventUtilityService = stripeEventUtilityService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.UpcomingInvoice"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
/// <exception cref="Exception"></exception>
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
{ {
var invoice = await _stripeEventService.GetInvoice(parsedEvent); var invoice = await stripeEventService.GetInvoice(parsedEvent);
if (string.IsNullOrEmpty(invoice.SubscriptionId)) if (string.IsNullOrEmpty(invoice.SubscriptionId))
{ {
_logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id); logger.LogInformation("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
return; return;
} }
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId, new SubscriptionGetOptions
if (subscription == null)
{ {
throw new Exception( Expand = ["customer.tax", "customer.tax_ids"]
$"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'"); });
}
var updatedSubscription = await TryEnableAutomaticTaxAsync(subscription); var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(updatedSubscription.Metadata);
var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
if (organizationId.HasValue) if (organizationId.HasValue)
{ {
if (_stripeEventUtilityService.IsSponsoredSubscription(updatedSubscription)) var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
{
var sponsorshipIsValid =
await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
if (!sponsorshipIsValid)
{
// If the sponsorship is invalid, then the subscription was updated to use the regular families plan
// price. Given that this is the case, we need the new invoice amount
subscription = await _stripeFacade.GetSubscription(subscription.Id,
new SubscriptionGetOptions { Expand = ["latest_invoice"] });
invoice = subscription.LatestInvoice; if (organization == null)
invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
}
}
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null || !OrgPlanForInvoiceNotifications(organization))
{ {
return; return;
} }
await SendEmails(new List<string> { organization.BillingEmail }); await TryEnableAutomaticTaxAsync(subscription);
if (!HasAnnualPlan(organization))
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
if (!sponsorshipIsValid)
{
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
/* /*
* TODO: https://bitwarden.atlassian.net/browse/PM-4862 * TODO: https://bitwarden.atlassian.net/browse/PM-4862
@ -113,66 +86,85 @@ public class UpcomingInvoiceHandler : IUpcomingInvoiceHandler
} }
else if (userId.HasValue) else if (userId.HasValue)
{ {
var user = await _userService.GetUserByIdAsync(userId.Value); var user = await userRepository.GetByIdAsync(userId.Value);
if (user?.Premium == true) if (user == null)
{ {
await SendEmails(new List<string> { user.Email }); return;
}
await TryEnableAutomaticTaxAsync(subscription);
if (user.Premium)
{
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
} }
} }
else if (providerId.HasValue) else 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(
"Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
parsedEvent.Id,
providerId.Value);
return; return;
} }
await SendEmails(new List<string> { provider.BillingEmail }); await TryEnableAutomaticTaxAsync(subscription);
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
}
} }
return; private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
/*
* Sends emails to the given email addresses.
*/
async Task SendEmails(IEnumerable<string> emails)
{ {
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{ {
await _mailService.SendInvoiceUpcoming( await mailService.SendInvoiceUpcoming(
validEmails, validEmails,
invoice.AmountDue / 100M, invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value, invoice.NextPaymentAttempt.Value,
invoiceLineItemDescriptions, items,
true); true);
} }
} }
}
private async Task<Subscription> TryEnableAutomaticTaxAsync(Subscription subscription) private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
{ {
var customerGetOptions = new CustomerGetOptions { Expand = ["tax"] }; if (subscription.AutomaticTax.Enabled ||
var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions); !subscription.Customer.HasBillingLocation() ||
IsNonTaxableNonUSBusinessUseSubscription(subscription))
var subscriptionUpdateOptions = new SubscriptionUpdateOptions();
if (!subscriptionUpdateOptions.EnableAutomaticTax(customer, subscription))
{ {
return subscription; return;
} }
return await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions); await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
DefaultTaxRates = [],
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
return;
bool IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription)
{
var familyPriceIds = new List<string>
{
// TODO: Replace with the PricingClient
StaticStore.GetPlan(PlanType.FamiliesAnnually2019).PasswordManager.StripePlanId,
StaticStore.GetPlan(PlanType.FamiliesAnnually).PasswordManager.StripePlanId
};
return localSubscription.Customer.Address.Country != "US" &&
localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
!localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() &&
!localSubscription.Customer.TaxIds.Any();
}
} }
private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual; private static bool HasAnnualPlan(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;
} }

View File

@ -5,6 +5,15 @@ namespace Bit.Core.Billing.Extensions;
public static class CustomerExtensions public static class CustomerExtensions
{ {
public static bool HasBillingLocation(this Customer customer)
=> customer is
{
Address:
{
Country: not null and not "",
PostalCode: not null and not ""
}
};
/// <summary> /// <summary>
/// Determines if a Stripe customer supports automatic tax /// Determines if a Stripe customer supports automatic tax