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:
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
29
src/Core/Models/Api/Response/TaxRateResponseModel.cs
Normal file
29
src/Core/Models/Api/Response/TaxRateResponseModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
85
src/Core/Models/Business/SubscriptionCreateOptions.cs
Normal file
85
src/Core/Models/Business/SubscriptionCreateOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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; }
|
||||
|
18
src/Core/Models/Table/TaxRate.cs
Normal file
18
src/Core/Models/Table/TaxRate.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
14
src/Core/Repositories/ITaxRateRepository.cs
Normal file
14
src/Core/Repositories/ITaxRateRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
70
src/Core/Repositories/SqlServer/TaxRateRepository.cs
Normal file
70
src/Core/Repositories/SqlServer/TaxRateRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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)
|
||||
|
Reference in New Issue
Block a user