1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 16:12:49 -05:00

Implemented tax collection for subscriptions (#1017)

* Implemented tax collection for subscriptions

* Cleanup for Sales Tax

* Cleanup for Sales Tax

* Changes a constraint to an index for checking purposes

* Added and implemented a ReadById method for TaxRate

* Code review fixes for Tax Rate implementation

* Code review fixes for Tax Rate implementation

* Made the SalesTax migration script rerunnable
This commit is contained in:
Addison Beck
2020-12-04 12:05:16 -05:00
committed by GitHub
parent 9e1bf3d584
commit b877c25234
27 changed files with 1157 additions and 88 deletions

View File

@ -14,6 +14,8 @@ namespace Bit.Core.Models.Api
[Range(0, 99)]
public short? AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; }
public string BillingAddressCountry { get; set; }
public string BillingAddressPostalCode { get; set; }
public OrganizationUpgrade ToOrganizationUpgrade()
{
@ -23,7 +25,12 @@ namespace Bit.Core.Models.Api
AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(),
BusinessName = BusinessName,
Plan = PlanType,
PremiumAccessAddon = PremiumAccessAddon
PremiumAccessAddon = PremiumAccessAddon,
TaxInfo = new TaxInfo()
{
BillingAddressCountry = BillingAddressCountry,
BillingAddressPostalCode = BillingAddressPostalCode
}
};
}
}

View File

@ -0,0 +1,29 @@
using System;
using Bit.Core.Models.Table;
namespace Bit.Core.Models.Api
{
public class TaxRateResponseModel : ResponseModel
{
public TaxRateResponseModel(TaxRate taxRate)
: base("profile")
{
if (taxRate == null)
{
throw new ArgumentNullException(nameof(taxRate));
}
Id = taxRate.Id;
Country = taxRate.Country;
State = taxRate.State;
PostalCode = taxRate.PostalCode;
Rate = taxRate.Rate;
}
public string Id { get; set; }
public string Country { get; set; }
public string State { get; set; }
public string PostalCode { get; set; }
public decimal Rate { get; set; }
}
}

View File

@ -9,5 +9,6 @@ namespace Bit.Core.Models.Business
public short AdditionalSeats { get; set; }
public short AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; }
public TaxInfo TaxInfo { get; set; }
}
}

View File

@ -0,0 +1,85 @@
using Bit.Core.Models.Table;
using Stripe;
using System.Collections.Generic;
namespace Bit.Core.Models.Business
{
public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOptions
{
public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, int additionalSeats, int additionalStorageGb, bool premiumAccessAddon)
{
Items = new List<SubscriptionItemOptions>();
Metadata = new Dictionary<string, string>
{
[org.GatewayIdField()] = org.Id.ToString()
};
if (plan.StripePlanId != null)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePlanId,
Quantity = 1
});
}
if (additionalSeats > 0 && plan.StripeSeatPlanId != null)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats
});
}
if (additionalStorageGb > 0)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeStoragePlanId,
Quantity = additionalStorageGb
});
}
if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePremiumAccessPlanId,
Quantity = 1
});
}
if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId))
{
DefaultTaxRates = new List<string>{ taxInfo.StripeTaxRateId };
}
}
}
public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase
{
public OrganizationPurchaseSubscriptionOptions(
Organization org, StaticStore.Plan plan,
TaxInfo taxInfo, int additionalSeats = 0,
int additionalStorageGb = 0, bool premiumAccessAddon = false) :
base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon)
{
OffSession = true;
TrialPeriodDays = plan.TrialPeriodDays;
}
}
public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOptionsBase
{
public OrganizationUpgradeSubscriptionOptions(
string customerId, Organization org,
StaticStore.Plan plan, TaxInfo taxInfo,
int additionalSeats = 0, int additionalStorageGb = 0,
bool premiumAccessAddon = false) :
base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon)
{
Customer = customerId;
}
}
}

View File

@ -14,6 +14,7 @@
_taxIdType = null;
}
}
public string StripeTaxRateId { get; set; }
public string BillingAddressLine1 { get; set; }
public string BillingAddressLine2 { get; set; }
public string BillingAddressCity { get; set; }

View File

@ -0,0 +1,18 @@
namespace Bit.Core.Models.Table
{
public class TaxRate: ITableObject<string>
{
public string Id { get; set; }
public string Country { get; set; }
public string State { get; set; }
public string PostalCode { get; set; }
public decimal Rate { get; set; }
public bool Active { get; set; }
public void SetNewId()
{
// Id is created by Stripe, should exist before this gets called
return;
}
}
}

View File

@ -0,0 +1,14 @@
using Bit.Core.Models.Table;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Bit.Core.Repositories
{
public interface ITaxRateRepository : IRepository<TaxRate, string>
{
Task<ICollection<TaxRate>> SearchAsync(int skip, int count);
Task<ICollection<TaxRate>> GetAllActiveAsync();
Task ArchiveAsync(TaxRate model);
Task<ICollection<TaxRate>> GetByLocationAsync(TaxRate taxRate);
}
}

View File

@ -0,0 +1,70 @@
using Bit.Core.Models.Table;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using Dapper;
using System.Linq;
namespace Bit.Core.Repositories.SqlServer
{
public class TaxRateRepository : Repository<TaxRate, string>, ITaxRateRepository
{
public TaxRateRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public TaxRateRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<TaxRate>> SearchAsync(int skip, int count)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<TaxRate>(
$"[{Schema}].[TaxRate_Search]",
new { Skip = skip, Count = count },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<ICollection<TaxRate>> GetAllActiveAsync()
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<TaxRate>(
$"[{Schema}].[TaxRate_ReadAllActive]",
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task ArchiveAsync(TaxRate model)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[TaxRate_Archive]",
new { Id = model.Id },
commandType: CommandType.StoredProcedure);
}
}
public async Task<ICollection<TaxRate>> GetByLocationAsync(TaxRate model)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<TaxRate>(
$"[{Schema}].[TaxRate_ReadByLocation]",
new { Country = model.Country, PostalCode = model.PostalCode },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}
}

View File

@ -12,7 +12,7 @@ namespace Bit.Core.Services
string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats,
bool premiumAccessAddon, TaxInfo taxInfo);
Task<string> UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan,
short additionalStorageGb, short additionalSeats, bool premiumAccessAddon);
short additionalStorageGb, short additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo);
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
short additionalStorageGb, TaxInfo taxInfo);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId);
@ -26,5 +26,8 @@ namespace Bit.Core.Services
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
Task<TaxRate> CreateTaxRateAsync(TaxRate taxRate);
Task UpdateTaxRateAsync(TaxRate taxRate);
Task ArchiveTaxRateAsync(TaxRate taxRate);
}
}

View File

@ -240,7 +240,7 @@ namespace Bit.Core.Services
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, newPlan,
upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon);
upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon, upgrade.TaxInfo);
success = string.IsNullOrWhiteSpace(paymentIntentClientSecret);
}
else

View File

@ -10,6 +10,8 @@ using Bit.Core.Enums;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
using Bit.Billing.Models;
using StripeTaxRate = Stripe.TaxRate;
using TaxRate = Bit.Core.Models.Table.TaxRate;
namespace Bit.Core.Services
{
@ -25,13 +27,15 @@ namespace Bit.Core.Services
private readonly IAppleIapService _appleIapService;
private readonly ILogger<StripePaymentService> _logger;
private readonly Braintree.BraintreeGateway _btGateway;
private readonly ITaxRateRepository _taxRateRepository;
public StripePaymentService(
ITransactionRepository transactionRepository,
IUserRepository userRepository,
GlobalSettings globalSettings,
IAppleIapService appleIapService,
ILogger<StripePaymentService> logger)
ILogger<StripePaymentService> logger,
ITaxRateRepository taxRateRepository)
{
_btGateway = new Braintree.BraintreeGateway
{
@ -45,6 +49,7 @@ namespace Bit.Core.Services
_userRepository = userRepository;
_appleIapService = appleIapService;
_logger = logger;
_taxRateRepository = taxRateRepository;
}
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
@ -98,52 +103,24 @@ namespace Bit.Core.Services
throw new GatewayException("Payment method is not supported at this time.");
}
var subCreateOptions = new SubscriptionCreateOptions
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
{
OffSession = true,
TrialPeriodDays = plan.TrialPeriodDays,
Items = new List<SubscriptionItemOptions>(),
Metadata = new Dictionary<string, string>
var taxRateSearch = new TaxRate()
{
[org.GatewayIdField()] = org.Id.ToString()
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode
};
var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch);
// should only be one tax rate per country/zip combo
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null)
{
taxInfo.StripeTaxRateId = taxRate.Id;
}
};
if (plan.StripePlanId != null)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePlanId,
Quantity = 1
});
}
if (additionalSeats > 0 && plan.StripeSeatPlanId != null)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats
});
}
if (additionalStorageGb > 0)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeStoragePlanId,
Quantity = additionalStorageGb
});
}
if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePremiumAccessPlanId,
Quantity = 1
});
}
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
Customer customer = null;
Subscription subscription;
@ -224,7 +201,7 @@ namespace Bit.Core.Services
}
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan,
short additionalStorageGb, short additionalSeats, bool premiumAccessAddon)
short additionalStorageGb, short additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
{
if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))
{
@ -241,52 +218,24 @@ namespace Bit.Core.Services
throw new GatewayException("Could not find customer payment profile.");
}
var subCreateOptions = new SubscriptionCreateOptions
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
{
Customer = customer.Id,
Items = new List<SubscriptionItemOptions>(),
Metadata = new Dictionary<string, string>
var taxRateSearch = new TaxRate()
{
[org.GatewayIdField()] = org.Id.ToString()
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode
};
var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch);
// should only be one tax rate per country/zip combo
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null)
{
taxInfo.StripeTaxRateId = taxRate.Id;
}
};
if (plan.StripePlanId != null)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePlanId,
Quantity = 1
});
}
if (additionalSeats > 0 && plan.StripeSeatPlanId != null)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats
});
}
if (additionalStorageGb > 0)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeStoragePlanId,
Quantity = additionalStorageGb
});
}
if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePremiumAccessPlanId,
Quantity = 1
});
}
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
var stripePaymentMethod = false;
var paymentMethodType = PaymentMethodType.Credit;
var hasBtCustomerId = customer.Metadata.ContainsKey("btCustomerId");
@ -1632,6 +1581,52 @@ namespace Bit.Core.Services
}
}
public async Task<TaxRate> CreateTaxRateAsync(TaxRate taxRate)
{
var stripeTaxRateOptions = new TaxRateCreateOptions()
{
DisplayName = $"{taxRate.Country} - {taxRate.PostalCode}",
Inclusive = false,
Percentage = taxRate.Rate,
Active = true
};
var taxRateService = new TaxRateService();
var stripeTaxRate = taxRateService.Create(stripeTaxRateOptions);
taxRate.Id = stripeTaxRate.Id;
await _taxRateRepository.CreateAsync(taxRate);
return taxRate;
}
public async Task UpdateTaxRateAsync(TaxRate taxRate)
{
if (string.IsNullOrWhiteSpace(taxRate.Id))
{
return;
}
await ArchiveTaxRateAsync(taxRate);
await CreateTaxRateAsync(taxRate);
}
public async Task ArchiveTaxRateAsync(TaxRate taxRate)
{
if (string.IsNullOrWhiteSpace(taxRate.Id))
{
return;
}
var stripeTaxRateService = new TaxRateService();
var updatedStripeTaxRate = await stripeTaxRateService.UpdateAsync(
taxRate.Id,
new TaxRateUpdateOptions() { Active = false }
);
if (!updatedStripeTaxRate.Active)
{
taxRate.Active = false;
await _taxRateRepository.ArchiveAsync(taxRate);
}
}
private PaymentMethod GetLatestCardPaymentMethod(string customerId)
{
var paymentMethodService = new PaymentMethodService();

View File

@ -79,6 +79,7 @@ namespace Bit.Core.Utilities
services.AddSingleton<ISsoConfigRepository, SqlServerRepos.SsoConfigRepository>();
services.AddSingleton<ISsoUserRepository, SqlServerRepos.SsoUserRepository>();
services.AddSingleton<ISendRepository, SqlServerRepos.SendRepository>();
services.AddSingleton<ITaxRateRepository, SqlServerRepos.TaxRateRepository>();
}
if (globalSettings.SelfHosted)