1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-06 00:11:22 +01:00

[PM-13999] show estimated tax for taxable countries (#5110)

This commit is contained in:
Jonas Hendrickx 2025-01-02 20:27:53 +01:00 committed by GitHub
parent 1062c6d522
commit 97e11774e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1806 additions and 548 deletions

View File

@ -0,0 +1,151 @@
using Bit.Core.Billing.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing;
[SutProviderCustomize]
public class TaxServiceTests
{
[Theory]
[BitAutoData("AD", "A-123456-Z", "ad_nrt")]
[BitAutoData("AD", "A123456Z", "ad_nrt")]
[BitAutoData("AR", "20-12345678-9", "ar_cuit")]
[BitAutoData("AR", "20123456789", "ar_cuit")]
[BitAutoData("AU", "01259983598", "au_abn")]
[BitAutoData("AU", "123456789123", "au_arn")]
[BitAutoData("AT", "ATU12345678", "eu_vat")]
[BitAutoData("BH", "123456789012345", "bh_vat")]
[BitAutoData("BY", "123456789", "by_tin")]
[BitAutoData("BE", "BE0123456789", "eu_vat")]
[BitAutoData("BO", "123456789", "bo_tin")]
[BitAutoData("BR", "01.234.456/5432-10", "br_cnpj")]
[BitAutoData("BR", "01234456543210", "br_cnpj")]
[BitAutoData("BR", "123.456.789-87", "br_cpf")]
[BitAutoData("BR", "12345678987", "br_cpf")]
[BitAutoData("BG", "123456789", "bg_uic")]
[BitAutoData("BG", "BG012100705", "eu_vat")]
[BitAutoData("CA", "100728494", "ca_bn")]
[BitAutoData("CA", "123456789RT0001", "ca_gst_hst")]
[BitAutoData("CA", "PST-1234-1234", "ca_pst_bc")]
[BitAutoData("CA", "123456-7", "ca_pst_mb")]
[BitAutoData("CA", "1234567", "ca_pst_sk")]
[BitAutoData("CA", "1234567890TQ1234", "ca_qst")]
[BitAutoData("CL", "11.121.326-1", "cl_tin")]
[BitAutoData("CL", "11121326-1", "cl_tin")]
[BitAutoData("CL", "23.121.326-K", "cl_tin")]
[BitAutoData("CL", "43651326-K", "cl_tin")]
[BitAutoData("CN", "123456789012345678", "cn_tin")]
[BitAutoData("CN", "123456789012345", "cn_tin")]
[BitAutoData("CO", "123.456.789-0", "co_nit")]
[BitAutoData("CO", "1234567890", "co_nit")]
[BitAutoData("CR", "1-234-567890", "cr_tin")]
[BitAutoData("CR", "1234567890", "cr_tin")]
[BitAutoData("HR", "HR12345678912", "eu_vat")]
[BitAutoData("HR", "12345678901", "hr_oib")]
[BitAutoData("CY", "CY12345678X", "eu_vat")]
[BitAutoData("CZ", "CZ12345678", "eu_vat")]
[BitAutoData("DK", "DK12345678", "eu_vat")]
[BitAutoData("DO", "123-4567890-1", "do_rcn")]
[BitAutoData("DO", "12345678901", "do_rcn")]
[BitAutoData("EC", "1234567890001", "ec_ruc")]
[BitAutoData("EG", "123456789", "eg_tin")]
[BitAutoData("SV", "1234-567890-123-4", "sv_nit")]
[BitAutoData("SV", "12345678901234", "sv_nit")]
[BitAutoData("EE", "EE123456789", "eu_vat")]
[BitAutoData("EU", "EU123456789", "eu_oss_vat")]
[BitAutoData("FI", "FI12345678", "eu_vat")]
[BitAutoData("FR", "FR12345678901", "eu_vat")]
[BitAutoData("GE", "123456789", "ge_vat")]
[BitAutoData("DE", "1234567890", "de_stn")]
[BitAutoData("DE", "DE123456789", "eu_vat")]
[BitAutoData("GR", "EL123456789", "eu_vat")]
[BitAutoData("HK", "12345678", "hk_br")]
[BitAutoData("HU", "HU12345678", "eu_vat")]
[BitAutoData("HU", "12345678-1-23", "hu_tin")]
[BitAutoData("HU", "12345678123", "hu_tin")]
[BitAutoData("IS", "123456", "is_vat")]
[BitAutoData("IN", "12ABCDE1234F1Z5", "in_gst")]
[BitAutoData("IN", "12ABCDE3456FGZH", "in_gst")]
[BitAutoData("ID", "012.345.678.9-012.345", "id_npwp")]
[BitAutoData("ID", "0123456789012345", "id_npwp")]
[BitAutoData("IE", "IE1234567A", "eu_vat")]
[BitAutoData("IE", "IE1234567AB", "eu_vat")]
[BitAutoData("IL", "000012345", "il_vat")]
[BitAutoData("IL", "123456789", "il_vat")]
[BitAutoData("IT", "IT12345678901", "eu_vat")]
[BitAutoData("JP", "1234567890123", "jp_cn")]
[BitAutoData("JP", "12345", "jp_rn")]
[BitAutoData("KZ", "123456789012", "kz_bin")]
[BitAutoData("KE", "P000111111A", "ke_pin")]
[BitAutoData("LV", "LV12345678912", "eu_vat")]
[BitAutoData("LI", "CHE123456789", "li_uid")]
[BitAutoData("LI", "12345", "li_vat")]
[BitAutoData("LT", "LT123456789123", "eu_vat")]
[BitAutoData("LU", "LU12345678", "eu_vat")]
[BitAutoData("MY", "12345678", "my_frp")]
[BitAutoData("MY", "C 1234567890", "my_itn")]
[BitAutoData("MY", "C1234567890", "my_itn")]
[BitAutoData("MY", "A12-3456-78912345", "my_sst")]
[BitAutoData("MY", "A12345678912345", "my_sst")]
[BitAutoData("MT", "MT12345678", "eu_vat")]
[BitAutoData("MX", "ABC010203AB9", "mx_rfc")]
[BitAutoData("MD", "1003600", "md_vat")]
[BitAutoData("MA", "12345678", "ma_vat")]
[BitAutoData("NL", "NL123456789B12", "eu_vat")]
[BitAutoData("NZ", "123456789", "nz_gst")]
[BitAutoData("NG", "12345678-0001", "ng_tin")]
[BitAutoData("NO", "123456789MVA", "no_vat")]
[BitAutoData("NO", "1234567", "no_voec")]
[BitAutoData("OM", "OM1234567890", "om_vat")]
[BitAutoData("PE", "12345678901", "pe_ruc")]
[BitAutoData("PH", "123456789012", "ph_tin")]
[BitAutoData("PL", "PL1234567890", "eu_vat")]
[BitAutoData("PT", "PT123456789", "eu_vat")]
[BitAutoData("RO", "RO1234567891", "eu_vat")]
[BitAutoData("RO", "1234567890123", "ro_tin")]
[BitAutoData("RU", "1234567891", "ru_inn")]
[BitAutoData("RU", "123456789", "ru_kpp")]
[BitAutoData("SA", "123456789012345", "sa_vat")]
[BitAutoData("RS", "123456789", "rs_pib")]
[BitAutoData("SG", "M12345678X", "sg_gst")]
[BitAutoData("SG", "123456789F", "sg_uen")]
[BitAutoData("SK", "SK1234567891", "eu_vat")]
[BitAutoData("SI", "SI12345678", "eu_vat")]
[BitAutoData("SI", "12345678", "si_tin")]
[BitAutoData("ZA", "4123456789", "za_vat")]
[BitAutoData("KR", "123-45-67890", "kr_brn")]
[BitAutoData("KR", "1234567890", "kr_brn")]
[BitAutoData("ES", "A12345678", "es_cif")]
[BitAutoData("ES", "ESX1234567X", "eu_vat")]
[BitAutoData("SE", "SE123456789012", "eu_vat")]
[BitAutoData("CH", "CHE-123.456.789 HR", "ch_uid")]
[BitAutoData("CH", "CHE123456789HR", "ch_uid")]
[BitAutoData("CH", "CHE-123.456.789 MWST", "ch_vat")]
[BitAutoData("CH", "CHE123456789MWST", "ch_vat")]
[BitAutoData("TW", "12345678", "tw_vat")]
[BitAutoData("TH", "1234567890123", "th_vat")]
[BitAutoData("TR", "0123456789", "tr_tin")]
[BitAutoData("UA", "123456789", "ua_vat")]
[BitAutoData("AE", "123456789012345", "ae_trn")]
[BitAutoData("GB", "XI123456789", "eu_vat")]
[BitAutoData("GB", "GB123456789", "gb_vat")]
[BitAutoData("US", "12-3456789", "us_ein")]
[BitAutoData("UY", "123456789012", "uy_ruc")]
[BitAutoData("UZ", "123456789", "uz_tin")]
[BitAutoData("UZ", "123456789012", "uz_vat")]
[BitAutoData("VE", "A-12345678-9", "ve_rif")]
[BitAutoData("VE", "A123456789", "ve_rif")]
[BitAutoData("VN", "1234567890", "vn_tin")]
public void GetStripeTaxCode_WithValidCountryAndTaxId_ReturnsExpectedTaxIdType(
string country,
string taxId,
string expected,
SutProvider<TaxService> sutProvider)
{
var result = sutProvider.Sut.GetStripeTaxCode(country, taxId);
Assert.Equal(expected, result);
}
}

View File

@ -666,7 +666,7 @@ public class AccountsController : Controller
new TaxInfo
{
BillingAddressCountry = model.Country,
BillingAddressPostalCode = model.PostalCode,
BillingAddressPostalCode = model.PostalCode
});
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
@ -721,8 +721,13 @@ public class AccountsController : Controller
await _userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType.Value,
new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressCountry = model.Country,
BillingAddressPostalCode = model.PostalCode,
TaxIdNumber = model.TaxId
});
}

View File

@ -1,5 +1,6 @@
#nullable enable
using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Bit.Core.Utilities;
@ -77,4 +78,18 @@ public class AccountsBillingController(
return TypedResults.Ok(transactions);
}
[HttpPost("preview-invoice")]
public async Task<IResult> PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId);
return TypedResults.Ok(invoice);
}
}

View File

@ -0,0 +1,42 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("invoices")]
[Authorize("Application")]
public class InvoicesController : BaseBillingController
{
[HttpPost("preview-organization")]
public async Task<IResult> PreviewInvoiceAsync(
[FromBody] PreviewOrganizationInvoiceRequestBody model,
[FromServices] ICurrentContext currentContext,
[FromServices] IOrganizationRepository organizationRepository,
[FromServices] IPaymentService paymentService)
{
Organization organization = null;
if (model.OrganizationId != default)
{
if (!await currentContext.EditPaymentMethods(model.OrganizationId))
{
return Error.Unauthorized();
}
organization = await organizationRepository.GetByIdAsync(model.OrganizationId);
if (organization == null)
{
return Error.NotFound();
}
}
var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId,
organization?.GatewaySubscriptionId);
return TypedResults.Ok(invoice);
}
}

View File

@ -119,6 +119,7 @@ public class ProviderBillingController(
requestBody.Country,
requestBody.PostalCode,
requestBody.TaxId,
requestBody.TaxIdType,
requestBody.Line1,
requestBody.Line2,
requestBody.City,

View File

@ -1,4 +1,5 @@
using Bit.Core.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@ -46,4 +47,15 @@ public class StripeController(
return TypedResults.Ok(setupIntent.ClientSecret);
}
[HttpGet]
[Route("~/tax/is-country-supported")]
public IResult IsCountrySupported(
[FromQuery] string country,
[FromServices] ITaxService taxService)
{
var isSupported = taxService.IsSupported(country);
return TypedResults.Ok(isSupported);
}
}

View File

@ -10,6 +10,7 @@ public class TaxInformationRequestBody
[Required]
public string PostalCode { get; set; }
public string TaxId { get; set; }
public string TaxIdType { get; set; }
public string Line1 { get; set; }
public string Line2 { get; set; }
public string City { get; set; }
@ -19,6 +20,7 @@ public class TaxInformationRequestBody
Country,
PostalCode,
TaxId,
TaxIdType,
Line1,
Line2,
City,

View File

@ -0,0 +1,33 @@
namespace Bit.Core.Billing.Extensions;
public static class CurrencyExtensions
{
/// <summary>
/// Converts a currency amount in major units to minor units.
/// </summary>
/// <example>123.99 USD returns 12399 in minor units.</example>
public static long ToMinor(this decimal amount)
{
return Convert.ToInt64(amount * 100);
}
/// <summary>
/// Converts a currency amount in minor units to major units.
/// </summary>
/// <param name="amount"></param>
/// <example>12399 in minor units returns 123.99 USD.</example>
public static decimal? ToMajor(this long? amount)
{
return amount?.ToMajor();
}
/// <summary>
/// Converts a currency amount in minor units to major units.
/// </summary>
/// <param name="amount"></param>
/// <example>12399 in minor units returns 123.99 USD.</example>
public static decimal ToMajor(this long amount)
{
return Convert.ToDecimal(amount) / 100;
}
}

View File

@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions
{
public static void AddBillingOperations(this IServiceCollection services)
{
services.AddSingleton<ITaxService, TaxService>();
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Billing.Models.Api.Requests.Accounts;
public class PreviewIndividualInvoiceRequestBody
{
[Required]
public PasswordManagerRequestModel PasswordManager { get; set; }
[Required]
public TaxInformationRequestModel TaxInformation { get; set; }
}
public class PasswordManagerRequestModel
{
[Range(0, int.MaxValue)]
public int AdditionalStorage { get; set; }
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Models.Api.Requests.Organizations;
public class PreviewOrganizationInvoiceRequestBody
{
public Guid OrganizationId { get; set; }
[Required]
public PasswordManagerRequestModel PasswordManager { get; set; }
public SecretsManagerRequestModel SecretsManager { get; set; }
[Required]
public TaxInformationRequestModel TaxInformation { get; set; }
}
public class PasswordManagerRequestModel
{
public PlanType Plan { get; set; }
[Range(0, int.MaxValue)]
public int Seats { get; set; }
[Range(0, int.MaxValue)]
public int AdditionalStorage { get; set; }
}
public class SecretsManagerRequestModel
{
[Range(0, int.MaxValue)]
public int Seats { get; set; }
[Range(0, int.MaxValue)]
public int AdditionalMachineAccounts { get; set; }
}

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Billing.Models.Api.Requests;
public class TaxInformationRequestModel
{
[Length(2, 2), Required]
public string Country { get; set; }
[Required]
public string PostalCode { get; set; }
public string TaxId { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Billing.Models.Api.Responses;
public record PreviewInvoiceResponseModel(
decimal EffectiveTaxRate,
decimal TaxableBaseAmount,
decimal TaxAmount,
decimal TotalAmount);

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Billing.Models;
public record PreviewInvoiceInfo(
decimal EffectiveTaxRate,
decimal TaxableBaseAmount,
decimal TaxAmount,
decimal TotalAmount);

View File

@ -65,6 +65,7 @@ public class OrganizationSale
signup.TaxInfo.BillingAddressCountry,
signup.TaxInfo.BillingAddressPostalCode,
signup.TaxInfo.TaxIdNumber,
signup.TaxInfo.TaxIdType,
signup.TaxInfo.BillingAddressLine1,
signup.TaxInfo.BillingAddressLine2,
signup.TaxInfo.BillingAddressCity,

View File

@ -0,0 +1,22 @@
using System.Text.RegularExpressions;
namespace Bit.Core.Billing.Models;
public class TaxIdType
{
/// <summary>
/// ISO-3166-2 code for the country.
/// </summary>
public string Country { get; set; }
/// <summary>
/// The identifier in Stripe for the tax ID type.
/// </summary>
public string Code { get; set; }
public Regex ValidationExpression { get; set; }
public string Description { get; set; }
public string Example { get; set; }
}

View File

@ -1,5 +1,4 @@
using Bit.Core.Models.Business;
using Stripe;
namespace Bit.Core.Billing.Models;
@ -7,6 +6,7 @@ public record TaxInformation(
string Country,
string PostalCode,
string TaxId,
string TaxIdType,
string Line1,
string Line2,
string City,
@ -16,165 +16,9 @@ public record TaxInformation(
taxInfo.BillingAddressCountry,
taxInfo.BillingAddressPostalCode,
taxInfo.TaxIdNumber,
taxInfo.TaxIdType,
taxInfo.BillingAddressLine1,
taxInfo.BillingAddressLine2,
taxInfo.BillingAddressCity,
taxInfo.BillingAddressState);
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
{
var address = new AddressOptions
{
Country = Country,
PostalCode = PostalCode,
Line1 = Line1,
Line2 = Line2,
City = City,
State = State
};
var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId)
? new List<CustomerTaxIdDataOptions> { new() { Type = GetTaxIdType(), Value = TaxId } }
: null;
return (address, customerTaxIdDataOptionsList);
}
public string GetTaxIdType()
{
if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId))
{
return null;
}
switch (Country.ToUpper())
{
case "AD":
return "ad_nrt";
case "AE":
return "ae_trn";
case "AR":
return "ar_cuit";
case "AU":
return "au_abn";
case "BO":
return "bo_tin";
case "BR":
return "br_cnpj";
case "CA":
// May break for those in Québec given the assumption of QST
if (State?.Contains("bec") ?? false)
{
return "ca_qst";
}
return "ca_bn";
case "CH":
return "ch_vat";
case "CL":
return "cl_tin";
case "CN":
return "cn_tin";
case "CO":
return "co_nit";
case "CR":
return "cr_tin";
case "DO":
return "do_rcn";
case "EC":
return "ec_ruc";
case "EG":
return "eg_tin";
case "GE":
return "ge_vat";
case "ID":
return "id_npwp";
case "IL":
return "il_vat";
case "IS":
return "is_vat";
case "KE":
return "ke_pin";
case "AT":
case "BE":
case "BG":
case "CY":
case "CZ":
case "DE":
case "DK":
case "EE":
case "ES":
case "FI":
case "FR":
case "GB":
case "GR":
case "HR":
case "HU":
case "IE":
case "IT":
case "LT":
case "LU":
case "LV":
case "MT":
case "NL":
case "PL":
case "PT":
case "RO":
case "SE":
case "SI":
case "SK":
return "eu_vat";
case "HK":
return "hk_br";
case "IN":
return "in_gst";
case "JP":
return "jp_cn";
case "KR":
return "kr_brn";
case "LI":
return "li_uid";
case "MX":
return "mx_rfc";
case "MY":
return "my_sst";
case "NO":
return "no_vat";
case "NZ":
return "nz_gst";
case "PE":
return "pe_ruc";
case "PH":
return "ph_tin";
case "RS":
return "rs_pib";
case "RU":
return "ru_inn";
case "SA":
return "sa_vat";
case "SG":
return "sg_gst";
case "SV":
return "sv_nit";
case "TH":
return "th_vat";
case "TR":
return "tr_tin";
case "TW":
return "tw_vat";
case "UA":
return "ua_vat";
case "US":
return "us_ein";
case "UY":
return "uy_ruc";
case "VE":
return "ve_rif";
case "VN":
return "vn_tin";
case "ZA":
return "za_vat";
default:
return null;
}
}
}

View File

@ -0,0 +1,22 @@
namespace Bit.Core.Billing.Services;
public interface ITaxService
{
/// <summary>
/// Retrieves the Stripe tax code for a given country and tax ID.
/// </summary>
/// <param name="country"></param>
/// <param name="taxId"></param>
/// <returns>
/// Returns the Stripe tax code if the tax ID is valid for the country.
/// Returns null if the tax ID is invalid or the country is not supported.
/// </returns>
string GetStripeTaxCode(string country, string taxId);
/// <summary>
/// Returns true or false whether charging or storing tax is supported for the given country.
/// </summary>
/// <param name="country"></param>
/// <returns></returns>
bool IsSupported(string country);
}

View File

@ -28,7 +28,8 @@ public class OrganizationBillingService(
IOrganizationRepository organizationRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService
ISubscriberService subscriberService,
ITaxService taxService) : IOrganizationBillingService
{
public async Task Finalize(OrganizationSale sale)
{
@ -173,14 +174,38 @@ public class OrganizationBillingService(
throw new BillingException();
}
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
customerCreateOptions.Address = address;
customerCreateOptions.Address = new AddressOptions
{
Line1 = customerSetup.TaxInformation.Line1,
Line2 = customerSetup.TaxInformation.Line2,
City = customerSetup.TaxInformation.City,
PostalCode = customerSetup.TaxInformation.PostalCode,
State = customerSetup.TaxInformation.State,
Country = customerSetup.TaxInformation.Country,
};
customerCreateOptions.Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
};
customerCreateOptions.TaxIdData = taxIdData;
if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId))
{
var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country,
customerSetup.TaxInformation.TaxId);
if (taxIdType == null)
{
logger.LogWarning("Could not determine tax ID type for organization '{OrganizationID}' in country '{Country}' with tax ID '{TaxID}'.",
organization.Id,
customerSetup.TaxInformation.Country,
customerSetup.TaxInformation.TaxId);
}
customerCreateOptions.TaxIdData =
[
new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId }
];
}
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;

View File

@ -82,13 +82,19 @@ public class PremiumUserBillingService(
throw new BillingException();
}
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = address,
Address = new AddressOptions
{
Line1 = customerSetup.TaxInformation.Line1,
Line2 = customerSetup.TaxInformation.Line2,
City = customerSetup.TaxInformation.City,
PostalCode = customerSetup.TaxInformation.PostalCode,
State = customerSetup.TaxInformation.State,
Country = customerSetup.TaxInformation.Country,
},
Description = user.Name,
Email = user.Email,
Expand = ["tax"],
@ -113,8 +119,7 @@ public class PremiumUserBillingService(
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
},
TaxIdData = taxIdData
}
};
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;

View File

@ -23,7 +23,8 @@ public class SubscriberService(
IGlobalSettings globalSettings,
ILogger<SubscriberService> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter) : ISubscriberService
IStripeAdapter stripeAdapter,
ITaxService taxService) : ISubscriberService
{
public async Task CancelSubscription(
ISubscriber subscriber,
@ -609,25 +610,54 @@ public class SubscriberService(
}
});
if (!subscriber.IsUser())
var taxId = customer.TaxIds?.FirstOrDefault();
if (taxId != null)
{
var taxId = customer.TaxIds?.FirstOrDefault();
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
}
if (taxId != null)
if (string.IsNullOrWhiteSpace(taxInformation.TaxId))
{
return;
}
var taxIdType = taxInformation.TaxIdType;
if (string.IsNullOrWhiteSpace(taxIdType))
{
taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
taxInformation.TaxId);
if (taxIdType == null)
{
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInformation.Country,
taxInformation.TaxId);
throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
}
}
var taxIdType = taxInformation.GetTaxIdType();
if (!string.IsNullOrWhiteSpace(taxInformation.TaxId) &&
!string.IsNullOrWhiteSpace(taxIdType))
try
{
await stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions
{
Type = taxIdType,
Value = taxInformation.TaxId,
});
case StripeConstants.ErrorCodes.TaxIdInvalid:
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
taxInformation.TaxId,
taxInformation.Country);
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
default:
logger.LogError(e,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
taxInformation.TaxId,
taxInformation.Country,
customer.Id);
throw new Exceptions.BadRequestException("billingTaxIdCreationError");
}
}
@ -636,8 +666,7 @@ public class SubscriberService(
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
DefaultTaxRates = []
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
@ -770,6 +799,7 @@ public class SubscriberService(
customer.Address.Country,
customer.Address.PostalCode,
customer.TaxIds?.FirstOrDefault()?.Value,
customer.TaxIds?.FirstOrDefault()?.Type,
customer.Address.Line1,
customer.Address.Line2,
customer.Address.City,

View File

@ -0,0 +1,901 @@
using System.Text.RegularExpressions;
using Bit.Core.Billing.Models;
namespace Bit.Core.Billing.Services;
public class TaxService : ITaxService
{
/// <summary>
/// Retrieves a list of supported tax ID types for customers.
/// </summary>
/// <remarks>Compiled list from <see href="https://docs.stripe.com/billing/customer/tax-ids">Stripe</see></remarks>
private static readonly IEnumerable<TaxIdType> _taxIdTypes =
[
new()
{
Country = "AD",
Code = "ad_nrt",
Description = "Andorran NRT number",
Example = "A-123456-Z",
ValidationExpression = new Regex("^([A-Z]{1})-?([0-9]{6})-?([A-Z]{1})$")
},
new()
{
Country = "AR",
Code = "ar_cuit",
Description = "Argentinian tax ID number",
Example = "12-34567890-1",
ValidationExpression = new Regex("^([0-9]{2})-?([0-9]{8})-?([0-9]{1})$")
},
new()
{
Country = "AU",
Code = "au_abn",
Description = "Australian Business Number (AU ABN)",
Example = "123456789012",
ValidationExpression = new Regex("^[0-9]{11}$")
},
new()
{
Country = "AU",
Code = "au_arn",
Description = "Australian Taxation Office Reference Number",
Example = "123456789123",
ValidationExpression = new Regex("^[0-9]{12}$")
},
new()
{
Country = "AT",
Code = "eu_vat",
Description = "European VAT number (Austria)",
Example = "ATU12345678",
ValidationExpression = new Regex("^ATU[0-9]{8}$")
},
new()
{
Country = "BH",
Code = "bh_vat",
Description = "Bahraini VAT Number",
Example = "123456789012345",
ValidationExpression = new Regex("^[0-9]{15}$")
},
new()
{
Country = "BY",
Code = "by_tin",
Description = "Belarus TIN Number",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "BE",
Code = "eu_vat",
Description = "European VAT number (Belgium)",
Example = "BE0123456789",
ValidationExpression = new Regex("^BE[0-9]{10}$")
},
new()
{
Country = "BO",
Code = "bo_tin",
Description = "Bolivian tax ID",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "BR",
Code = "br_cnpj",
Description = "Brazilian CNPJ number",
Example = "01.234.456/5432-10",
ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}/?[0-9]{4}-?[0-9]{2}$")
},
new()
{
Country = "BR",
Code = "br_cpf",
Description = "Brazilian CPF number",
Example = "123.456.789-87",
ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{2}$")
},
new()
{
Country = "BG",
Code = "bg_uic",
Description = "Bulgaria Unified Identification Code",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "BG",
Code = "eu_vat",
Description = "European VAT number (Bulgaria)",
Example = "BG0123456789",
ValidationExpression = new Regex("^BG[0-9]{9,10}$")
},
new()
{
Country = "CA",
Code = "ca_bn",
Description = "Canadian BN",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "CA",
Code = "ca_gst_hst",
Description = "Canadian GST/HST number",
Example = "123456789RT0002",
ValidationExpression = new Regex("^[0-9]{9}RT[0-9]{4}$")
},
new()
{
Country = "CA",
Code = "ca_pst_bc",
Description = "Canadian PST number (British Columbia)",
Example = "PST-1234-5678",
ValidationExpression = new Regex("^PST-[0-9]{4}-[0-9]{4}$")
},
new()
{
Country = "CA",
Code = "ca_pst_mb",
Description = "Canadian PST number (Manitoba)",
Example = "123456-7",
ValidationExpression = new Regex("^[0-9]{6}-[0-9]{1}$")
},
new()
{
Country = "CA",
Code = "ca_pst_sk",
Description = "Canadian PST number (Saskatchewan)",
Example = "1234567",
ValidationExpression = new Regex("^[0-9]{7}$")
},
new()
{
Country = "CA",
Code = "ca_qst",
Description = "Canadian QST number (Québec)",
Example = "1234567890TQ1234",
ValidationExpression = new Regex("^[0-9]{10}TQ[0-9]{4}$")
},
new()
{
Country = "CL",
Code = "cl_tin",
Description = "Chilean TIN",
Example = "12.345.678-K",
ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}-?[0-9A-Z]{1}$")
},
new()
{
Country = "CN",
Code = "cn_tin",
Description = "Chinese tax ID",
Example = "123456789012345678",
ValidationExpression = new Regex("^[0-9]{15,18}$")
},
new()
{
Country = "CO",
Code = "co_nit",
Description = "Colombian NIT number",
Example = "123.456.789-0",
ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{1}$")
},
new()
{
Country = "CR",
Code = "cr_tin",
Description = "Costa Rican tax ID",
Example = "1-234-567890",
ValidationExpression = new Regex("^[0-9]{1}-?[0-9]{3}-?[0-9]{6}$")
},
new()
{
Country = "HR",
Code = "eu_vat",
Description = "European VAT number (Croatia)",
Example = "HR12345678912",
ValidationExpression = new Regex("^HR[0-9]{11}$")
},
new()
{
Country = "HR",
Code = "hr_oib",
Description = "Croatian Personal Identification Number",
Example = "12345678901",
ValidationExpression = new Regex("^[0-9]{11}$")
},
new()
{
Country = "CY",
Code = "eu_vat",
Description = "European VAT number (Cyprus)",
Example = "CY12345678X",
ValidationExpression = new Regex("^CY[0-9]{8}[A-Z]{1}$")
},
new()
{
Country = "CZ",
Code = "eu_vat",
Description = "European VAT number (Czech Republic)",
Example = "CZ12345678",
ValidationExpression = new Regex("^CZ[0-9]{8,10}$")
},
new()
{
Country = "DK",
Code = "eu_vat",
Description = "European VAT number (Denmark)",
Example = "DK12345678",
ValidationExpression = new Regex("^DK[0-9]{8}$")
},
new()
{
Country = "DO",
Code = "do_rcn",
Description = "Dominican RCN number",
Example = "123-4567890-1",
ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{7}-?[0-9]{1}$")
},
new()
{
Country = "EC",
Code = "ec_ruc",
Description = "Ecuadorian RUC number",
Example = "1234567890001",
ValidationExpression = new Regex("^[0-9]{13}$")
},
new()
{
Country = "EG",
Code = "eg_tin",
Description = "Egyptian Tax Identification Number",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "SV",
Code = "sv_nit",
Description = "El Salvadorian NIT number",
Example = "1234-567890-123-4",
ValidationExpression = new Regex("^[0-9]{4}-?[0-9]{6}-?[0-9]{3}-?[0-9]{1}$")
},
new()
{
Country = "EE",
Code = "eu_vat",
Description = "European VAT number (Estonia)",
Example = "EE123456789",
ValidationExpression = new Regex("^EE[0-9]{9}$")
},
new()
{
Country = "EU",
Code = "eu_oss_vat",
Description = "European One Stop Shop VAT number for non-Union scheme",
Example = "EU123456789",
ValidationExpression = new Regex("^EU[0-9]{9}$")
},
new()
{
Country = "FI",
Code = "eu_vat",
Description = "European VAT number (Finland)",
Example = "FI12345678",
ValidationExpression = new Regex("^FI[0-9]{8}$")
},
new()
{
Country = "FR",
Code = "eu_vat",
Description = "European VAT number (France)",
Example = "FR12345678901",
ValidationExpression = new Regex("^FR[0-9A-Z]{2}[0-9]{9}$")
},
new()
{
Country = "GE",
Code = "ge_vat",
Description = "Georgian VAT",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "DE",
Code = "de_stn",
Description = "German Tax Number (Steuernummer)",
Example = "1234567890",
ValidationExpression = new Regex("^[0-9]{10}$")
},
new()
{
Country = "DE",
Code = "eu_vat",
Description = "European VAT number (Germany)",
Example = "DE123456789",
ValidationExpression = new Regex("^DE[0-9]{9}$")
},
new()
{
Country = "GR",
Code = "eu_vat",
Description = "European VAT number (Greece)",
Example = "EL123456789",
ValidationExpression = new Regex("^EL[0-9]{9}$")
},
new()
{
Country = "HK",
Code = "hk_br",
Description = "Hong Kong BR number",
Example = "12345678",
ValidationExpression = new Regex("^[0-9]{8}$")
},
new()
{
Country = "HU",
Code = "eu_vat",
Description = "European VAT number (Hungaria)",
Example = "HU12345678",
ValidationExpression = new Regex("^HU[0-9]{8}$")
},
new()
{
Country = "HU",
Code = "hu_tin",
Description = "Hungary tax number (adószám)",
Example = "12345678-1-23",
ValidationExpression = new Regex("^[0-9]{8}-?[0-9]-?[0-9]{2}$")
},
new()
{
Country = "IS",
Code = "is_vat",
Description = "Icelandic VAT",
Example = "123456",
ValidationExpression = new Regex("^[0-9]{6}$")
},
new()
{
Country = "IN",
Code = "in_gst",
Description = "Indian GST number",
Example = "12ABCDE3456FGZH",
ValidationExpression = new Regex("^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$")
},
new()
{
Country = "ID",
Code = "id_npwp",
Description = "Indonesian NPWP number",
Example = "012.345.678.9-012.345",
ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}.?[0-9]{1}-?[0-9]{3}.?[0-9]{3}$")
},
new()
{
Country = "IE",
Code = "eu_vat",
Description = "European VAT number (Ireland)",
Example = "IE1234567AB",
ValidationExpression = new Regex("^IE[0-9]{7}[A-Z]{1,2}$")
},
new()
{
Country = "IL",
Code = "il_vat",
Description = "Israel VAT",
Example = "000012345",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "IT",
Code = "eu_vat",
Description = "European VAT number (Italy)",
Example = "IT12345678912",
ValidationExpression = new Regex("^IT[0-9]{11}$")
},
new()
{
Country = "JP",
Code = "jp_cn",
Description = "Japanese Corporate Number (*Hōjin Bangō*)",
Example = "1234567891234",
ValidationExpression = new Regex("^[0-9]{13}$")
},
new()
{
Country = "JP",
Code = "jp_rn",
Description =
"Japanese Registered Foreign Businesses' Registration Number (*Tōroku Kokugai Jigyōsha no Tōroku Bangō*)",
Example = "12345",
ValidationExpression = new Regex("^[0-9]{5}$")
},
new()
{
Country = "JP",
Code = "jp_trn",
Description = "Japanese Tax Registration Number (*Tōroku Bangō*)",
Example = "T1234567891234",
ValidationExpression = new Regex("^T[0-9]{13}$")
},
new()
{
Country = "KZ",
Code = "kz_bin",
Description = "Kazakhstani Business Identification Number",
Example = "123456789012",
ValidationExpression = new Regex("^[0-9]{12}$")
},
new()
{
Country = "KE",
Code = "ke_pin",
Description = "Kenya Revenue Authority Personal Identification Number",
Example = "P000111111A",
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{9}[A-Z]{1}$")
},
new()
{
Country = "LV",
Code = "eu_vat",
Description = "European VAT number",
Example = "LV12345678912",
ValidationExpression = new Regex("^LV[0-9]{11}$")
},
new()
{
Country = "LI",
Code = "li_uid",
Description = "Liechtensteinian UID number",
Example = "CHE123456789",
ValidationExpression = new Regex("^CHE[0-9]{9}$")
},
new()
{
Country = "LI",
Code = "li_vat",
Description = "Liechtensteinian VAT number",
Example = "12345",
ValidationExpression = new Regex("^[0-9]{5}$")
},
new()
{
Country = "LT",
Code = "eu_vat",
Description = "European VAT number (Lithuania)",
Example = "LT123456789123",
ValidationExpression = new Regex("^LT[0-9]{9,12}$")
},
new()
{
Country = "LU",
Code = "eu_vat",
Description = "European VAT number (Luxembourg)",
Example = "LU12345678",
ValidationExpression = new Regex("^LU[0-9]{8}$")
},
new()
{
Country = "MY",
Code = "my_frp",
Description = "Malaysian FRP number",
Example = "12345678",
ValidationExpression = new Regex("^[0-9]{8}$")
},
new()
{
Country = "MY",
Code = "my_itn",
Description = "Malaysian ITN",
Example = "C 1234567890",
ValidationExpression = new Regex("^[A-Z]{1} ?[0-9]{10}$")
},
new()
{
Country = "MY",
Code = "my_sst",
Description = "Malaysian SST number",
Example = "A12-3456-78912345",
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{2}-?[0-9]{4}-?[0-9]{8}$")
},
new()
{
Country = "MT",
Code = "eu_vat",
Description = "European VAT number (Malta)",
Example = "MT12345678",
ValidationExpression = new Regex("^MT[0-9]{8}$")
},
new()
{
Country = "MX",
Code = "mx_rfc",
Description = "Mexican RFC number",
Example = "ABC010203AB9",
ValidationExpression = new Regex("^[A-Z]{3}[0-9]{6}[A-Z0-9]{3}$")
},
new()
{
Country = "MD",
Code = "md_vat",
Description = "Moldova VAT Number",
Example = "1234567",
ValidationExpression = new Regex("^[0-9]{7}$")
},
new()
{
Country = "MA",
Code = "ma_vat",
Description = "Morocco VAT Number",
Example = "12345678",
ValidationExpression = new Regex("^[0-9]{8}$")
},
new()
{
Country = "NL",
Code = "eu_vat",
Description = "European VAT number (Netherlands)",
Example = "NL123456789B12",
ValidationExpression = new Regex("^NL[0-9]{9}B[0-9]{2}$")
},
new()
{
Country = "NZ",
Code = "nz_gst",
Description = "New Zealand GST number",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "NG",
Code = "ng_tin",
Description = "Nigerian TIN Number",
Example = "12345678-0001",
ValidationExpression = new Regex("^[0-9]{8}-[0-9]{4}$")
},
new()
{
Country = "NO",
Code = "no_vat",
Description = "Norwegian VAT number",
Example = "123456789MVA",
ValidationExpression = new Regex("^[0-9]{9}MVA$")
},
new()
{
Country = "NO",
Code = "no_voec",
Description = "Norwegian VAT on e-commerce number",
Example = "1234567",
ValidationExpression = new Regex("^[0-9]{7}$")
},
new()
{
Country = "OM",
Code = "om_vat",
Description = "Omani VAT Number",
Example = "OM1234567890",
ValidationExpression = new Regex("^OM[0-9]{10}$")
},
new()
{
Country = "PE",
Code = "pe_ruc",
Description = "Peruvian RUC number",
Example = "12345678901",
ValidationExpression = new Regex("^[0-9]{11}$")
},
new()
{
Country = "PH",
Code = "ph_tin",
Description = "Philippines Tax Identification Number",
Example = "123456789012",
ValidationExpression = new Regex("^[0-9]{12}$")
},
new()
{
Country = "PL",
Code = "eu_vat",
Description = "European VAT number (Poland)",
Example = "PL1234567890",
ValidationExpression = new Regex("^PL[0-9]{10}$")
},
new()
{
Country = "PT",
Code = "eu_vat",
Description = "European VAT number (Portugal)",
Example = "PT123456789",
ValidationExpression = new Regex("^PT[0-9]{9}$")
},
new()
{
Country = "RO",
Code = "eu_vat",
Description = "European VAT number (Romania)",
Example = "RO1234567891",
ValidationExpression = new Regex("^RO[0-9]{2,10}$")
},
new()
{
Country = "RO",
Code = "ro_tin",
Description = "Romanian tax ID number",
Example = "1234567890123",
ValidationExpression = new Regex("^[0-9]{13}$")
},
new()
{
Country = "RU",
Code = "ru_inn",
Description = "Russian INN",
Example = "1234567891",
ValidationExpression = new Regex("^[0-9]{10,12}$")
},
new()
{
Country = "RU",
Code = "ru_kpp",
Description = "Russian KPP",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "SA",
Code = "sa_vat",
Description = "Saudi Arabia VAT",
Example = "123456789012345",
ValidationExpression = new Regex("^[0-9]{15}$")
},
new()
{
Country = "RS",
Code = "rs_pib",
Description = "Serbian PIB number",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "SG",
Code = "sg_gst",
Description = "Singaporean GST",
Example = "M12345678X",
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}[A-Z]{1}$")
},
new()
{
Country = "SG",
Code = "sg_uen",
Description = "Singaporean UEN",
Example = "123456789F",
ValidationExpression = new Regex("^[0-9]{9}[A-Z]{1}$")
},
new()
{
Country = "SK",
Code = "eu_vat",
Description = "European VAT number (Slovakia)",
Example = "SK1234567891",
ValidationExpression = new Regex("^SK[0-9]{10}$")
},
new()
{
Country = "SI",
Code = "eu_vat",
Description = "European VAT number (Slovenia)",
Example = "SI12345678",
ValidationExpression = new Regex("^SI[0-9]{8}$")
},
new()
{
Country = "SI",
Code = "si_tin",
Description = "Slovenia tax number (davčna številka)",
Example = "12345678",
ValidationExpression = new Regex("^[0-9]{8}$")
},
new()
{
Country = "ZA",
Code = "za_vat",
Description = "South African VAT number",
Example = "4123456789",
ValidationExpression = new Regex("^[0-9]{10}$")
},
new()
{
Country = "KR",
Code = "kr_brn",
Description = "Korean BRN",
Example = "123-45-67890",
ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{2}-?[0-9]{5}$")
},
new()
{
Country = "ES",
Code = "es_cif",
Description = "Spanish NIF/CIF number",
Example = "A12345678",
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}$")
},
new()
{
Country = "ES",
Code = "eu_vat",
Description = "European VAT number (Spain)",
Example = "ESA1234567Z",
ValidationExpression = new Regex("^ES[A-Z]{1}[0-9]{7}[A-Z]{1}$")
},
new()
{
Country = "SE",
Code = "eu_vat",
Description = "European VAT number (Sweden)",
Example = "SE123456789123",
ValidationExpression = new Regex("^SE[0-9]{12}$")
},
new()
{
Country = "CH",
Code = "ch_uid",
Description = "Switzerland UID number",
Example = "CHE-123.456.789 HR",
ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?HR$")
},
new()
{
Country = "CH",
Code = "ch_vat",
Description = "Switzerland VAT number",
Example = "CHE-123.456.789 MWST",
ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?MWST$")
},
new()
{
Country = "TW",
Code = "tw_vat",
Description = "Taiwanese VAT",
Example = "12345678",
ValidationExpression = new Regex("^[0-9]{8}$")
},
new()
{
Country = "TZ",
Code = "tz_vat",
Description = "Tanzania VAT Number",
Example = "12345678A",
ValidationExpression = new Regex("^[0-9]{8}[A-Z]{1}$")
},
new()
{
Country = "TH",
Code = "th_vat",
Description = "Thai VAT",
Example = "1234567891234",
ValidationExpression = new Regex("^[0-9]{13}$")
},
new()
{
Country = "TR",
Code = "tr_tin",
Description = "Turkish TIN Number",
Example = "0123456789",
ValidationExpression = new Regex("^[0-9]{10}$")
},
new()
{
Country = "UA",
Code = "ua_vat",
Description = "Ukrainian VAT",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "AE",
Code = "ae_trn",
Description = "United Arab Emirates TRN",
Example = "123456789012345",
ValidationExpression = new Regex("^[0-9]{15}$")
},
new()
{
Country = "GB",
Code = "eu_vat",
Description = "Northern Ireland VAT number",
Example = "XI123456789",
ValidationExpression = new Regex("^XI[0-9]{9}$")
},
new()
{
Country = "GB",
Code = "gb_vat",
Description = "United Kingdom VAT number",
Example = "GB123456789",
ValidationExpression = new Regex("^GB[0-9]{9}$")
},
new()
{
Country = "US",
Code = "us_ein",
Description = "United States EIN",
Example = "12-3456789",
ValidationExpression = new Regex("^[0-9]{2}-?[0-9]{7}$")
},
new()
{
Country = "UY",
Code = "uy_ruc",
Description = "Uruguayan RUC number",
Example = "123456789012",
ValidationExpression = new Regex("^[0-9]{12}$")
},
new()
{
Country = "UZ",
Code = "uz_tin",
Description = "Uzbekistan TIN Number",
Example = "123456789",
ValidationExpression = new Regex("^[0-9]{9}$")
},
new()
{
Country = "UZ",
Code = "uz_vat",
Description = "Uzbekistan VAT Number",
Example = "123456789012",
ValidationExpression = new Regex("^[0-9]{12}$")
},
new()
{
Country = "VE",
Code = "ve_rif",
Description = "Venezuelan RIF number",
Example = "A-12345678-9",
ValidationExpression = new Regex("^[A-Z]{1}-?[0-9]{8}-?[0-9]{1}$")
},
new()
{
Country = "VN",
Code = "vn_tin",
Description = "Vietnamese tax ID number",
Example = "1234567890",
ValidationExpression = new Regex("^[0-9]{10}$")
}
];
public string GetStripeTaxCode(string country, string taxId)
{
foreach (var taxIdType in _taxIdTypes.Where(x => x.Country == country))
{
if (taxIdType.ValidationExpression.IsMatch(taxId))
{
return taxIdType.Code;
}
}
return null;
}
public bool IsSupported(string country)
{
return _taxIdTypes.Any(x => x.Country == country);
}
}

View File

@ -83,6 +83,7 @@ public static class Utilities
customer.Address.Country,
customer.Address.PostalCode,
customer.TaxIds?.FirstOrDefault()?.Value,
customer.TaxIds?.FirstOrDefault()?.Type,
customer.Address.Line1,
customer.Address.Line2,
customer.Address.City,

View File

@ -2,18 +2,9 @@
public class TaxInfo
{
private string _taxIdNumber = null;
private string _taxIdType = null;
public string TaxIdNumber { get; set; }
public string TaxIdType { get; set; }
public string TaxIdNumber
{
get => _taxIdNumber;
set
{
_taxIdNumber = value;
_taxIdType = null;
}
}
public string StripeTaxRateId { get; set; }
public string BillingAddressLine1 { get; set; }
public string BillingAddressLine2 { get; set; }
@ -21,201 +12,6 @@ public class TaxInfo
public string BillingAddressState { get; set; }
public string BillingAddressPostalCode { get; set; }
public string BillingAddressCountry { get; set; } = "US";
public string TaxIdType
{
get
{
if (string.IsNullOrWhiteSpace(BillingAddressCountry) ||
string.IsNullOrWhiteSpace(TaxIdNumber))
{
return null;
}
if (!string.IsNullOrWhiteSpace(_taxIdType))
{
return _taxIdType;
}
switch (BillingAddressCountry.ToUpper())
{
case "AD":
_taxIdType = "ad_nrt";
break;
case "AE":
_taxIdType = "ae_trn";
break;
case "AR":
_taxIdType = "ar_cuit";
break;
case "AU":
_taxIdType = "au_abn";
break;
case "BO":
_taxIdType = "bo_tin";
break;
case "BR":
_taxIdType = "br_cnpj";
break;
case "CA":
// May break for those in Québec given the assumption of QST
if (BillingAddressState?.Contains("bec") ?? false)
{
_taxIdType = "ca_qst";
break;
}
_taxIdType = "ca_bn";
break;
case "CH":
_taxIdType = "ch_vat";
break;
case "CL":
_taxIdType = "cl_tin";
break;
case "CN":
_taxIdType = "cn_tin";
break;
case "CO":
_taxIdType = "co_nit";
break;
case "CR":
_taxIdType = "cr_tin";
break;
case "DO":
_taxIdType = "do_rcn";
break;
case "EC":
_taxIdType = "ec_ruc";
break;
case "EG":
_taxIdType = "eg_tin";
break;
case "GE":
_taxIdType = "ge_vat";
break;
case "ID":
_taxIdType = "id_npwp";
break;
case "IL":
_taxIdType = "il_vat";
break;
case "IS":
_taxIdType = "is_vat";
break;
case "KE":
_taxIdType = "ke_pin";
break;
case "AT":
case "BE":
case "BG":
case "CY":
case "CZ":
case "DE":
case "DK":
case "EE":
case "ES":
case "FI":
case "FR":
case "GB":
case "GR":
case "HR":
case "HU":
case "IE":
case "IT":
case "LT":
case "LU":
case "LV":
case "MT":
case "NL":
case "PL":
case "PT":
case "RO":
case "SE":
case "SI":
case "SK":
_taxIdType = "eu_vat";
break;
case "HK":
_taxIdType = "hk_br";
break;
case "IN":
_taxIdType = "in_gst";
break;
case "JP":
_taxIdType = "jp_cn";
break;
case "KR":
_taxIdType = "kr_brn";
break;
case "LI":
_taxIdType = "li_uid";
break;
case "MX":
_taxIdType = "mx_rfc";
break;
case "MY":
_taxIdType = "my_sst";
break;
case "NO":
_taxIdType = "no_vat";
break;
case "NZ":
_taxIdType = "nz_gst";
break;
case "PE":
_taxIdType = "pe_ruc";
break;
case "PH":
_taxIdType = "ph_tin";
break;
case "RS":
_taxIdType = "rs_pib";
break;
case "RU":
_taxIdType = "ru_inn";
break;
case "SA":
_taxIdType = "sa_vat";
break;
case "SG":
_taxIdType = "sg_gst";
break;
case "SV":
_taxIdType = "sv_nit";
break;
case "TH":
_taxIdType = "th_vat";
break;
case "TR":
_taxIdType = "tr_tin";
break;
case "TW":
_taxIdType = "tw_vat";
break;
case "UA":
_taxIdType = "ua_vat";
break;
case "US":
_taxIdType = "us_ein";
break;
case "UY":
_taxIdType = "uy_ruc";
break;
case "VE":
_taxIdType = "ve_rif";
break;
case "VN":
_taxIdType = "vn_tin";
break;
case "ZA":
_taxIdType = "za_vat";
break;
default:
_taxIdType = null;
break;
}
return _taxIdType;
}
}
public bool HasTaxId
{

View File

@ -1,6 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Models.Api.Responses;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
@ -59,4 +62,7 @@ public interface IPaymentService
Task<bool> RisksSubscriptionFailure(Organization organization);
Task<bool> HasSecretsManagerStandalone(Organization organization);
Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription);
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
}

View File

@ -31,6 +31,7 @@ public interface IStripeAdapter
Task<Stripe.Invoice> InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options);
Task<Stripe.Invoice> InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options);
Task<List<Stripe.Invoice>> InvoiceListAsync(StripeInvoiceListOptions options);
Task<Stripe.Invoice> InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options);
Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options);
Task<Stripe.Invoice> InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options);
Task<Stripe.Invoice> InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options);
@ -42,6 +43,7 @@ public interface IStripeAdapter
IAsyncEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options);
Task<Stripe.PaymentMethod> PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null);
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
Task<Stripe.Plan> PlanGetAsync(string id, Stripe.PlanGetOptions options = null);
Task<Stripe.TaxRate> TaxRateCreateAsync(Stripe.TaxRateCreateOptions options);
Task<Stripe.TaxRate> TaxRateUpdateAsync(string id, Stripe.TaxRateUpdateOptions options);
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);

View File

@ -15,6 +15,7 @@ public class StripeAdapter : IStripeAdapter
private readonly Stripe.RefundService _refundService;
private readonly Stripe.CardService _cardService;
private readonly Stripe.BankAccountService _bankAccountService;
private readonly Stripe.PlanService _planService;
private readonly Stripe.PriceService _priceService;
private readonly Stripe.SetupIntentService _setupIntentService;
private readonly Stripe.TestHelpers.TestClockService _testClockService;
@ -33,6 +34,7 @@ public class StripeAdapter : IStripeAdapter
_cardService = new Stripe.CardService();
_bankAccountService = new Stripe.BankAccountService();
_priceService = new Stripe.PriceService();
_planService = new Stripe.PlanService();
_setupIntentService = new SetupIntentService();
_testClockService = new Stripe.TestHelpers.TestClockService();
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
@ -133,6 +135,11 @@ public class StripeAdapter : IStripeAdapter
return invoices;
}
public Task<Invoice> InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options)
{
return _invoiceService.CreatePreviewAsync(options);
}
public async Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options)
=> (await _invoiceService.SearchAsync(options)).Data;
@ -184,6 +191,11 @@ public class StripeAdapter : IStripeAdapter
return _paymentMethodService.DetachAsync(id, options);
}
public Task<Stripe.Plan> PlanGetAsync(string id, Stripe.PlanGetOptions options = null)
{
return _planService.GetAsync(id, options);
}
public Task<Stripe.TaxRate> TaxRateCreateAsync(Stripe.TaxRateCreateOptions options)
{
return _taxRateService.CreateAsync(options);

View File

@ -1,8 +1,13 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
using Bit.Core.Billing.Models.Api.Responses;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -32,6 +37,8 @@ public class StripePaymentService : IPaymentService
private readonly IStripeAdapter _stripeAdapter;
private readonly IGlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly ITaxService _taxService;
private readonly ISubscriberService _subscriberService;
public StripePaymentService(
ITransactionRepository transactionRepository,
@ -40,7 +47,9 @@ public class StripePaymentService : IPaymentService
IStripeAdapter stripeAdapter,
Braintree.IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
IFeatureService featureService)
IFeatureService featureService,
ITaxService taxService,
ISubscriberService subscriberService)
{
_transactionRepository = transactionRepository;
_logger = logger;
@ -49,6 +58,8 @@ public class StripePaymentService : IPaymentService
_btGateway = braintreeGateway;
_globalSettings = globalSettings;
_featureService = featureService;
_taxService = taxService;
_subscriberService = subscriberService;
}
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
@ -112,6 +123,20 @@ public class StripePaymentService : IPaymentService
Subscription subscription;
try
{
if (taxInfo.TaxIdNumber != null && taxInfo.TaxIdType == null)
{
taxInfo.TaxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxInfo.TaxIdType == null)
{
_logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
}
var customerCreateOptions = new CustomerCreateOptions
{
Description = org.DisplayBusinessName(),
@ -146,12 +171,9 @@ public class StripePaymentService : IPaymentService
City = taxInfo?.BillingAddressCity,
State = taxInfo?.BillingAddressState,
},
TaxIdData = taxInfo?.HasTaxId != true
? null
:
[
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, }
],
TaxIdData = taxInfo.HasTaxId
? [new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }]
: null
};
customerCreateOptions.AddExpand("tax");
@ -1372,6 +1394,12 @@ public class StripePaymentService : IPaymentService
try
{
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
{
taxInfo.TaxIdType = taxInfo.TaxIdType ??
_taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber);
}
if (customer == null)
{
customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions
@ -1401,8 +1429,17 @@ public class StripePaymentService : IPaymentService
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState,
State = taxInfo.BillingAddressState
},
TaxIdData = string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)
? []
: [
new CustomerTaxIdDataOptions
{
Type = taxInfo.TaxIdType,
Value = taxInfo.TaxIdNumber
}
],
Expand = ["sources", "tax", "subscriptions"],
});
@ -1458,6 +1495,8 @@ public class StripePaymentService : IPaymentService
await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new PaymentMethodDetachOptions());
}
await _subscriberService.UpdateTaxInformation(subscriber, TaxInformation.From(taxInfo));
customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = stripeCustomerMetadata,
@ -1474,15 +1513,6 @@ public class StripePaymentService : IPaymentService
}
]
},
Address = taxInfo == null ? null : new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState,
},
Expand = ["tax", "subscriptions"]
});
}
@ -1659,6 +1689,7 @@ public class StripePaymentService : IPaymentService
return new TaxInfo
{
TaxIdNumber = taxId?.Value,
TaxIdType = taxId?.Type,
BillingAddressLine1 = address?.Line1,
BillingAddressLine2 = address?.Line2,
BillingAddressCity = address?.City,
@ -1670,9 +1701,13 @@ public class StripePaymentService : IPaymentService
public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo)
{
if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser())
{
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions
return;
}
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
Address = new AddressOptions
{
@ -1686,23 +1721,59 @@ public class StripePaymentService : IPaymentService
Expand = ["tax_ids"]
});
if (!subscriber.IsUser() && customer != null)
{
var taxId = customer.TaxIds?.FirstOrDefault();
if (customer == null)
{
return;
}
if (taxId != null)
{
await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
}
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) &&
!string.IsNullOrWhiteSpace(taxInfo.TaxIdType))
{
await _stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions
{
Type = taxInfo.TaxIdType,
Value = taxInfo.TaxIdNumber,
});
}
var taxId = customer.TaxIds?.FirstOrDefault();
if (taxId != null)
{
await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
}
if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
{
return;
}
var taxIdType = taxInfo.TaxIdType;
if (string.IsNullOrWhiteSpace(taxIdType))
{
taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber);
if (taxIdType == null)
{
_logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
}
try
{
await _stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, });
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
case StripeConstants.ErrorCodes.TaxIdInvalid:
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
taxInfo.TaxIdNumber,
taxInfo.BillingAddressCountry);
throw new BadRequestException("billingInvalidTaxIdError");
default:
_logger.LogError(e,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
taxInfo.TaxIdNumber,
taxInfo.BillingAddressCountry,
customer.Id);
throw new BadRequestException("billingTaxIdCreationError");
}
}
}
@ -1835,6 +1906,285 @@ public class StripePaymentService : IPaymentService
}
}
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
PreviewIndividualInvoiceRequestBody parameters,
string gatewayCustomerId,
string gatewaySubscriptionId)
{
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = true,
},
Currency = "usd",
Discounts = new List<InvoiceDiscountOptions>(),
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items =
[
new()
{
Quantity = 1,
Plan = "premium-annually"
},
new()
{
Quantity = parameters.PasswordManager.AdditionalStorage,
Plan = "storage-gb-annually"
}
]
},
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Address = new AddressOptions
{
PostalCode = parameters.TaxInformation.PostalCode,
Country = parameters.TaxInformation.Country,
}
},
};
if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId))
{
var taxIdType = _taxService.GetStripeTaxCode(
options.CustomerDetails.Address.Country,
parameters.TaxInformation.TaxId);
if (taxIdType == null)
{
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvalidTaxIdError");
}
options.CustomerDetails.TaxIds = [
new InvoiceCustomerDetailsTaxIdOptions
{
Type = taxIdType,
Value = parameters.TaxInformation.TaxId
}
];
}
if (gatewayCustomerId != null)
{
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
if (gatewayCustomer.Discount != null)
{
options.Discounts.Add(new InvoiceDiscountOptions
{
Discount = gatewayCustomer.Discount.Id
});
}
if (gatewaySubscriptionId != null)
{
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
if (gatewaySubscription?.Discount != null)
{
options.Discounts.Add(new InvoiceDiscountOptions
{
Discount = gatewaySubscription.Discount.Id
});
}
}
}
try
{
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null
? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
: 0M;
var result = new PreviewInvoiceResponseModel(
effectiveTaxRate,
invoice.TotalExcludingTax.ToMajor() ?? 0,
invoice.Tax.ToMajor() ?? 0,
invoice.Total.ToMajor());
return result;
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
case StripeConstants.ErrorCodes.TaxIdInvalid:
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvalidTaxIdError");
default:
_logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvoiceError");
}
}
}
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
PreviewOrganizationInvoiceRequestBody parameters,
string gatewayCustomerId,
string gatewaySubscriptionId)
{
var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan);
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = true,
},
Currency = "usd",
Discounts = new List<InvoiceDiscountOptions>(),
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items =
[
new()
{
Quantity = parameters.PasswordManager.AdditionalStorage,
Plan = plan.PasswordManager.StripeStoragePlanId
}
]
},
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Address = new AddressOptions
{
PostalCode = parameters.TaxInformation.PostalCode,
Country = parameters.TaxInformation.Country,
}
},
};
if (plan.PasswordManager.HasAdditionalSeatsOption)
{
options.SubscriptionDetails.Items.Add(
new()
{
Quantity = parameters.PasswordManager.Seats,
Plan = plan.PasswordManager.StripeSeatPlanId
}
);
}
else
{
options.SubscriptionDetails.Items.Add(
new()
{
Quantity = 1,
Plan = plan.PasswordManager.StripePlanId
}
);
}
if (plan.SupportsSecretsManager)
{
if (plan.SecretsManager.HasAdditionalSeatsOption)
{
options.SubscriptionDetails.Items.Add(new()
{
Quantity = parameters.SecretsManager?.Seats ?? 0,
Plan = plan.SecretsManager.StripeSeatPlanId
});
}
if (plan.SecretsManager.HasAdditionalServiceAccountOption)
{
options.SubscriptionDetails.Items.Add(new()
{
Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0,
Plan = plan.SecretsManager.StripeServiceAccountPlanId
});
}
}
if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId))
{
var taxIdType = _taxService.GetStripeTaxCode(
options.CustomerDetails.Address.Country,
parameters.TaxInformation.TaxId);
if (taxIdType == null)
{
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.CustomerDetails.TaxIds = [
new InvoiceCustomerDetailsTaxIdOptions
{
Type = taxIdType,
Value = parameters.TaxInformation.TaxId
}
];
}
if (gatewayCustomerId != null)
{
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
if (gatewayCustomer.Discount != null)
{
options.Discounts.Add(new InvoiceDiscountOptions
{
Discount = gatewayCustomer.Discount.Id
});
}
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
if (gatewaySubscription?.Discount != null)
{
options.Discounts.Add(new InvoiceDiscountOptions
{
Discount = gatewaySubscription.Discount.Id
});
}
}
try
{
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null
? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
: 0M;
var result = new PreviewInvoiceResponseModel(
effectiveTaxRate,
invoice.TotalExcludingTax.ToMajor() ?? 0,
invoice.Tax.ToMajor() ?? 0,
invoice.Total.ToMajor());
return result;
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
case StripeConstants.ErrorCodes.TaxIdInvalid:
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvalidTaxIdError");
default:
_logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvoiceError");
}
}
}
private PaymentMethod GetLatestCardPaymentMethod(string customerId)
{
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(

View File

@ -973,6 +973,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
await paymentService.CancelAndRecoverChargesAsync(user);
throw;
}
return new Tuple<bool, string>(string.IsNullOrWhiteSpace(paymentIntentClientSecret),
paymentIntentClientSecret);
}

View File

@ -1545,7 +1545,7 @@ public class SubscriberServiceTests
{
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1" }] } };
var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList<TaxId> { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } };
stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is<CustomerGetOptions>(
options => options.Expand.Contains("tax_ids"))).Returns(customer);
@ -1554,6 +1554,7 @@ public class SubscriberServiceTests
"US",
"12345",
"123456789",
"us_ein",
"123 Example St.",
null,
"Example Town",

View File

@ -1,114 +0,0 @@
using Bit.Core.Models.Business;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class TaxInfoTests
{
// PH = Placeholder
[Theory]
[InlineData(null, null, null, null)]
[InlineData("", "", null, null)]
[InlineData("PH", "", null, null)]
[InlineData("", "PH", null, null)]
[InlineData("AE", "PH", null, "ae_trn")]
[InlineData("AU", "PH", null, "au_abn")]
[InlineData("BR", "PH", null, "br_cnpj")]
[InlineData("CA", "PH", "bec", "ca_qst")]
[InlineData("CA", "PH", null, "ca_bn")]
[InlineData("CL", "PH", null, "cl_tin")]
[InlineData("AT", "PH", null, "eu_vat")]
[InlineData("BE", "PH", null, "eu_vat")]
[InlineData("BG", "PH", null, "eu_vat")]
[InlineData("CY", "PH", null, "eu_vat")]
[InlineData("CZ", "PH", null, "eu_vat")]
[InlineData("DE", "PH", null, "eu_vat")]
[InlineData("DK", "PH", null, "eu_vat")]
[InlineData("EE", "PH", null, "eu_vat")]
[InlineData("ES", "PH", null, "eu_vat")]
[InlineData("FI", "PH", null, "eu_vat")]
[InlineData("FR", "PH", null, "eu_vat")]
[InlineData("GB", "PH", null, "eu_vat")]
[InlineData("GR", "PH", null, "eu_vat")]
[InlineData("HR", "PH", null, "eu_vat")]
[InlineData("HU", "PH", null, "eu_vat")]
[InlineData("IE", "PH", null, "eu_vat")]
[InlineData("IT", "PH", null, "eu_vat")]
[InlineData("LT", "PH", null, "eu_vat")]
[InlineData("LU", "PH", null, "eu_vat")]
[InlineData("LV", "PH", null, "eu_vat")]
[InlineData("MT", "PH", null, "eu_vat")]
[InlineData("NL", "PH", null, "eu_vat")]
[InlineData("PL", "PH", null, "eu_vat")]
[InlineData("PT", "PH", null, "eu_vat")]
[InlineData("RO", "PH", null, "eu_vat")]
[InlineData("SE", "PH", null, "eu_vat")]
[InlineData("SI", "PH", null, "eu_vat")]
[InlineData("SK", "PH", null, "eu_vat")]
[InlineData("HK", "PH", null, "hk_br")]
[InlineData("IN", "PH", null, "in_gst")]
[InlineData("JP", "PH", null, "jp_cn")]
[InlineData("KR", "PH", null, "kr_brn")]
[InlineData("LI", "PH", null, "li_uid")]
[InlineData("MX", "PH", null, "mx_rfc")]
[InlineData("MY", "PH", null, "my_sst")]
[InlineData("NO", "PH", null, "no_vat")]
[InlineData("NZ", "PH", null, "nz_gst")]
[InlineData("RU", "PH", null, "ru_inn")]
[InlineData("SA", "PH", null, "sa_vat")]
[InlineData("SG", "PH", null, "sg_gst")]
[InlineData("TH", "PH", null, "th_vat")]
[InlineData("TW", "PH", null, "tw_vat")]
[InlineData("US", "PH", null, "us_ein")]
[InlineData("ZA", "PH", null, "za_vat")]
[InlineData("ABCDEF", "PH", null, null)]
public void GetTaxIdType_Success(string billingAddressCountry,
string taxIdNumber,
string billingAddressState,
string expectedTaxIdType)
{
var taxInfo = new TaxInfo
{
BillingAddressCountry = billingAddressCountry,
TaxIdNumber = taxIdNumber,
BillingAddressState = billingAddressState,
};
Assert.Equal(expectedTaxIdType, taxInfo.TaxIdType);
}
[Fact]
public void GetTaxIdType_CreateOnce_ReturnCacheSecondTime()
{
var taxInfo = new TaxInfo
{
BillingAddressCountry = "US",
TaxIdNumber = "PH",
BillingAddressState = null,
};
Assert.Equal("us_ein", taxInfo.TaxIdType);
// Per the current spec even if the values change to something other than null it
// will return the cached version of TaxIdType.
taxInfo.BillingAddressCountry = "ZA";
Assert.Equal("us_ein", taxInfo.TaxIdType);
}
[Theory]
[InlineData(null, null, false)]
[InlineData("123", "US", true)]
[InlineData("123", "ZQ12", false)]
[InlineData(" ", "US", false)]
public void HasTaxId_ReturnsExpected(string taxIdNumber, string billingAddressCountry, bool expected)
{
var taxInfo = new TaxInfo
{
TaxIdNumber = taxIdNumber,
BillingAddressCountry = billingAddressCountry,
};
Assert.Equal(expected, taxInfo.HasTaxId);
}
}

View File

@ -77,7 +77,8 @@ public class StripePaymentServiceTests
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
@ -134,7 +135,8 @@ public class StripePaymentServiceTests
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
@ -190,7 +192,8 @@ public class StripePaymentServiceTests
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
@ -247,7 +250,8 @@ public class StripePaymentServiceTests
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
@ -441,7 +445,8 @@ public class StripePaymentServiceTests
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
@ -510,7 +515,8 @@ public class StripePaymentServiceTests
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>