diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index d37bf41428..13e4a3fb9e 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,8 +1,7 @@ using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,7 +20,9 @@ public class UpcomingInvoiceHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, - IValidateSponsorshipCommand validateSponsorshipCommand) + IValidateSponsorshipCommand validateSponsorshipCommand, + IIndividualAutomaticTaxStrategy individualAutomaticTaxStrategy, + IOrganizationAutomaticTaxStrategy organizationAutomaticTaxStrategy) : IUpcomingInvoiceHandler { public async Task HandleAsync(Event parsedEvent) @@ -136,33 +137,15 @@ public class UpcomingInvoiceHandler( private async Task TryEnableAutomaticTaxAsync(Subscription subscription) { - if (subscription.AutomaticTax.Enabled || - !subscription.Customer.HasBillingLocation() || - await IsNonTaxableNonUSBusinessUseSubscription(subscription)) + var updateOptions = subscription.IsOrganization() + ? await organizationAutomaticTaxStrategy.GetUpdateOptionsAsync(subscription) + : await individualAutomaticTaxStrategy.GetUpdateOptionsAsync(subscription); + + if (updateOptions == null) { return; } - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - DefaultTaxRates = [], - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); - - return; - - async Task IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) - { - var familyPriceIds = (await Task.WhenAll( - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) - .Select(plan => plan.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(); - } + await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 080416e2bb..326023e34c 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -47,6 +47,8 @@ public static class StripeConstants public static class MetadataKeys { public const string OrganizationId = "organizationId"; + public const string ProviderId = "providerId"; + public const string UserId = "userId"; } public static class PaymentBehavior diff --git a/src/Core/Billing/Extensions/SubscriptionExtensions.cs b/src/Core/Billing/Extensions/SubscriptionExtensions.cs new file mode 100644 index 0000000000..63480e3b60 --- /dev/null +++ b/src/Core/Billing/Extensions/SubscriptionExtensions.cs @@ -0,0 +1,12 @@ +using Bit.Core.Billing.Constants; +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class SubscriptionExtensions +{ + public static bool IsOrganization(this Subscription subscription) + { + return subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId); + } +} diff --git a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Services/IAutomaticTaxStrategy.cs new file mode 100644 index 0000000000..26f6fd989d --- /dev/null +++ b/src/Core/Billing/Services/IAutomaticTaxStrategy.cs @@ -0,0 +1,10 @@ +using Stripe; + +namespace Bit.Core.Billing.Services; + +public interface IAutomaticTaxStrategy +{ + Task GetUpdateOptionsAsync(Subscription subscription); + Task SetCreateOptionsAsync(SubscriptionCreateOptions options, Customer customer = null); + Task SetUpdateOptionsAsync(SubscriptionUpdateOptions options, Subscription subscription); +} diff --git a/src/Core/Billing/Services/IIndividualAutomaticTaxStrategy.cs b/src/Core/Billing/Services/IIndividualAutomaticTaxStrategy.cs new file mode 100644 index 0000000000..8e6f7d7bec --- /dev/null +++ b/src/Core/Billing/Services/IIndividualAutomaticTaxStrategy.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Billing.Services; + +public interface IIndividualAutomaticTaxStrategy : IAutomaticTaxStrategy; diff --git a/src/Core/Billing/Services/IOrganizationAutomaticTaxStrategy.cs b/src/Core/Billing/Services/IOrganizationAutomaticTaxStrategy.cs new file mode 100644 index 0000000000..eef418a2d7 --- /dev/null +++ b/src/Core/Billing/Services/IOrganizationAutomaticTaxStrategy.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Billing.Services; + +public interface IOrganizationAutomaticTaxStrategy : IAutomaticTaxStrategy; diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/IndividualAutomaticTaxStrategy.cs b/src/Core/Billing/Services/Implementations/AutomaticTax/IndividualAutomaticTaxStrategy.cs new file mode 100644 index 0000000000..f28398b84c --- /dev/null +++ b/src/Core/Billing/Services/Implementations/AutomaticTax/IndividualAutomaticTaxStrategy.cs @@ -0,0 +1,45 @@ +using Stripe; + +namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; + +public class IndividualAutomaticTaxStrategy : IIndividualAutomaticTaxStrategy +{ + public Task SetCreateOptionsAsync(SubscriptionCreateOptions options, Customer customer = null) + { + ArgumentNullException.ThrowIfNull(options); + options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + return Task.CompletedTask; + } + + public Task SetUpdateOptionsAsync(SubscriptionUpdateOptions options, Subscription subscription) + { + + ArgumentNullException.ThrowIfNull(options); + + if (subscription.AutomaticTax.Enabled) + { + return Task.CompletedTask; + } + + options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + return Task.CompletedTask; + } + + public Task GetUpdateOptionsAsync(Subscription subscription) + { + if (subscription.AutomaticTax.Enabled) + { + return null; + } + + var options = new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + } + }; + + return Task.FromResult(options); + } +} diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/OrganizationAutomaticTaxStrategy.cs b/src/Core/Billing/Services/Implementations/AutomaticTax/OrganizationAutomaticTaxStrategy.cs new file mode 100644 index 0000000000..dfdbfd04b4 --- /dev/null +++ b/src/Core/Billing/Services/Implementations/AutomaticTax/OrganizationAutomaticTaxStrategy.cs @@ -0,0 +1,111 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Stripe; + +namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; + +public class OrganizationAutomaticTaxStrategy( + IPricingClient pricingClient) : IOrganizationAutomaticTaxStrategy +{ + public async Task GetUpdateOptionsAsync(Subscription subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + + var isEnabled = await IsEnabledAsync(subscription); + if (!isEnabled.HasValue) + { + return null; + } + + var options = new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = isEnabled.Value + } + }; + + return options; + } + + public async Task SetCreateOptionsAsync(SubscriptionCreateOptions options, Customer customer = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(customer); + + options.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = await IsEnabledAsync(options, customer) + }; + } + + public async Task SetUpdateOptionsAsync(SubscriptionUpdateOptions options, Subscription subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + + if (subscription.AutomaticTax.Enabled == options.AutomaticTax?.Enabled) + { + return; + } + + var isEnabled = await IsEnabledAsync(subscription); + if (!isEnabled.HasValue) + { + return; + } + + options.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = isEnabled.Value + }; + } + + private async Task IsEnabledAsync(Subscription subscription) + { + if (subscription.AutomaticTax.Enabled || + !subscription.Customer.HasBillingLocation() || + await IsNonTaxableNonUsBusinessUseSubscriptionAsync(subscription)) + { + return null; + } + + return !await IsNonTaxableNonUsBusinessUseSubscriptionAsync(subscription); + } + + private async Task IsNonTaxableNonUsBusinessUseSubscriptionAsync(Subscription subscription) + { + var familyPriceIds = (await Task.WhenAll( + pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), + pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) + .Select(plan => plan.PasswordManager.StripePlanId); + + return subscription.Customer.Address.Country != "US" && + subscription.IsOrganization() && + !subscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() && + !subscription.Customer.TaxIds.Any(); + } + + private async Task IsEnabledAsync(SubscriptionCreateOptions options, Customer customer) + { + if (!customer.HasBillingLocation() || + await IsNonTaxableNonUsBusinessUseSubscriptionAsync(options, customer)) + { + return null; + } + + return !await IsNonTaxableNonUsBusinessUseSubscriptionAsync(options, customer); + } + + private async Task IsNonTaxableNonUsBusinessUseSubscriptionAsync(SubscriptionCreateOptions options, Customer customer) + { + var familyPriceIds = (await Task.WhenAll( + pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), + pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) + .Select(plan => plan.PasswordManager.StripePlanId); + + return customer.Address.Country != "US" && + !options.Items.Select(item => item.Price).Intersect(familyPriceIds).Any() && + !customer.TaxIds.Any(); + } +}