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:
parent
66feebd358
commit
622ef902ed
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user