From d88838f19ead941c2e6b5a531aa4f52c7c35f260 Mon Sep 17 00:00:00 2001 From: Chad Scharf <3904944+cscharf@users.noreply.github.com> Date: Mon, 8 Jun 2020 17:40:18 -0400 Subject: [PATCH] API updates for tax info collection --- src/Api/Controllers/AccountsController.cs | 33 +++++ .../Controllers/OrganizationsController.cs | 50 +++++++ .../Accounts/TaxInfoUpdateRequestModel.cs | 21 +++ .../OrganizationCreateRequestModel.cs | 32 +++- .../OrganizationTaxInfoUpdateRequestModel.cs | 11 ++ .../Api/Response/TaxInfoResponseModel.cs | 34 +++++ .../Models/Business/OrganizationSignup.cs | 1 + src/Core/Models/Business/TaxInfo.cs | 140 ++++++++++++++++++ src/Core/Services/IPaymentService.cs | 4 +- .../Implementations/OrganizationService.cs | 2 +- .../Implementations/StripePaymentService.cs | 88 ++++++++++- 11 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 src/Core/Models/Api/Request/Accounts/TaxInfoUpdateRequestModel.cs create mode 100644 src/Core/Models/Api/Request/Organizations/OrganizationTaxInfoUpdateRequestModel.cs create mode 100644 src/Core/Models/Api/Response/TaxInfoResponseModel.cs create mode 100644 src/Core/Models/Business/TaxInfo.cs diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 7e235e345..a69cffb7f 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -618,5 +618,38 @@ namespace Bit.Api.Controllers return token; } + + [HttpGet("tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetTaxInfo() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var taxInfo = await _paymentService.GetTaxInfoAsync(user); + return new TaxInfoResponseModel(taxInfo); + } + + [HttpPut("tax")] + [HttpPost("tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PutTaxInfo([FromBody]TaxInfoUpdateRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var taxInfo = new TaxInfo + { + BillingAddressPostalCode = model.PostalCode, + BillingAddressCountry = model.Country, + }; + await _paymentService.SaveTaxInfoAsync(user, taxInfo); + } } } diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 6e86f84c9..ccb881dc5 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -462,5 +462,55 @@ namespace Bit.Api.Controllers return response; } } + + [HttpGet("{id}/tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetTaxInfo(string id) + { + var orgIdGuid = new Guid(id); + if (!_currentContext.OrganizationOwner(orgIdGuid)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); + if (organization == null) + { + throw new NotFoundException(); + } + + var taxInfo = await _paymentService.GetTaxInfoAsync(organization); + return new TaxInfoResponseModel(taxInfo); + } + + [HttpPut("{id}/tax")] + [HttpPost("{id}/tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PutTaxInfo(string id, [FromBody]OrganizationTaxInfoUpdateRequestModel model) + { + var orgIdGuid = new Guid(id); + if (!_currentContext.OrganizationOwner(orgIdGuid)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); + if (organization == null) + { + throw new NotFoundException(); + } + + var taxInfo = new TaxInfo + { + TaxIdNumber = model.TaxId, + BillingAddressLine1 = model.Line1, + BillingAddressLine2 = model.Line2, + BillingAddressCity = model.City, + BillingAddressState = model.State, + BillingAddressPostalCode = model.PostalCode, + BillingAddressCountry = model.Country, + }; + await _paymentService.SaveTaxInfoAsync(organization, taxInfo); + } } } diff --git a/src/Core/Models/Api/Request/Accounts/TaxInfoUpdateRequestModel.cs b/src/Core/Models/Api/Request/Accounts/TaxInfoUpdateRequestModel.cs new file mode 100644 index 000000000..809b1c743 --- /dev/null +++ b/src/Core/Models/Api/Request/Accounts/TaxInfoUpdateRequestModel.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class TaxInfoUpdateRequestModel : IValidatableObject + { + [Required] + public string Country { get; set; } + public string PostalCode { get; set; } + + public virtual IEnumerable Validate (ValidationContext validationContext) + { + if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + { + yield return new ValidationResult("Zip / postal code is required.", + new string[] { nameof(PostalCode) }); + } + } + } +} diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs index 7ee6992ed..14639a445 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs @@ -31,6 +31,15 @@ namespace Bit.Core.Models.Api [EncryptedString] [EncryptedStringLength(1000)] public string CollectionName { get; set; } + public string TaxIdNumber { get; set; } + public string BillingAddressLine1 { get; set; } + public string BillingAddressLine2 { get; set; } + public string BillingAddressCity { get; set; } + public string BillingAddressState { get; set; } + public string BillingAddressPostalCode { get; set; } + [Required] + [StringLength(2)] + public string BillingAddressCountry { get; set; } public virtual OrganizationSignup ToOrganizationSignup(User user) { @@ -47,7 +56,17 @@ namespace Bit.Core.Models.Api PremiumAccessAddon = PremiumAccessAddon, BillingEmail = BillingEmail, BusinessName = BusinessName, - CollectionName = CollectionName + CollectionName = CollectionName, + TaxInfo = new TaxInfo + { + TaxIdNumber = TaxIdNumber, + BillingAddressLine1 = BillingAddressLine1, + BillingAddressLine2 = BillingAddressLine2, + BillingAddressCity = BillingAddressCity, + BillingAddressState = BillingAddressState, + BillingAddressPostalCode = BillingAddressPostalCode, + BillingAddressCountry = BillingAddressCountry, + }, }; } @@ -62,6 +81,17 @@ namespace Bit.Core.Models.Api yield return new ValidationResult("Payment method type required.", new string[] { nameof(PaymentMethodType) }); } + if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry)) + { + yield return new ValidationResult("Country required.", + new string[] { nameof(BillingAddressCountry) }); + } + if (PlanType != PlanType.Free && BillingAddressCountry == "US" && + string.IsNullOrWhiteSpace(BillingAddressPostalCode)) + { + yield return new ValidationResult("Zip / postal code is required.", + new string[] { nameof(BillingAddressPostalCode) }); + } } } } diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationTaxInfoUpdateRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationTaxInfoUpdateRequestModel.cs new file mode 100644 index 000000000..a0a89f2b3 --- /dev/null +++ b/src/Core/Models/Api/Request/Organizations/OrganizationTaxInfoUpdateRequestModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Api +{ + public class OrganizationTaxInfoUpdateRequestModel : TaxInfoUpdateRequestModel + { + public string TaxId { get; set; } + public string Line1 { get; set; } + public string Line2 { get; set; } + public string City { get; set; } + public string State { get; set; } + } +} diff --git a/src/Core/Models/Api/Response/TaxInfoResponseModel.cs b/src/Core/Models/Api/Response/TaxInfoResponseModel.cs new file mode 100644 index 000000000..a39c88364 --- /dev/null +++ b/src/Core/Models/Api/Response/TaxInfoResponseModel.cs @@ -0,0 +1,34 @@ +using Bit.Core.Models.Business; + +namespace Bit.Core.Models.Api +{ + public class TaxInfoResponseModel + { + public TaxInfoResponseModel () { } + public TaxInfoResponseModel(TaxInfo taxInfo) + { + if (taxInfo == null) + { + return; + } + + TaxIdNumber = taxInfo.TaxIdNumber; + TaxIdType = taxInfo.TaxIdType; + Line1 = taxInfo.BillingAddressLine1; + Line2 = taxInfo.BillingAddressLine2; + City = taxInfo.BillingAddressCity; + State = taxInfo.BillingAddressState; + PostalCode = taxInfo.BillingAddressPostalCode; + Country = taxInfo.BillingAddressCountry; + } + + public string TaxIdNumber { get; set; } + public string TaxIdType { get; set; } + public string Line1 { get; set; } + public string Line2 { get; set; } + public string City { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + } +} diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index 5e00c1f24..61f1824cf 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -12,5 +12,6 @@ namespace Bit.Core.Models.Business public string CollectionName { get; set; } public PaymentMethodType? PaymentMethodType { get; set; } public string PaymentToken { get; set; } + public TaxInfo TaxInfo { get; set; } } } diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs new file mode 100644 index 000000000..283c033e7 --- /dev/null +++ b/src/Core/Models/Business/TaxInfo.cs @@ -0,0 +1,140 @@ +namespace Bit.Core.Models.Business +{ + public class TaxInfo + { + private string _taxIdNumber = null; + private string _taxIdType = null; + + public string TaxIdNumber + { + get => _taxIdNumber; + set + { + _taxIdNumber = value; + _taxIdType = null; + } + } + public string BillingAddressLine1 { get; set; } + public string BillingAddressLine2 { get; set; } + public string BillingAddressCity { get; set; } + 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) + { + case "AE": + _taxIdType = "ae_trn"; + break; + case "AU": + _taxIdType = "au_abn"; + 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"; + _taxIdType = "ca_bn"; + break; + case "CL": + _taxIdType = "cl_tin"; + 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 "RU": + _taxIdType = "ru_inn"; + break; + case "SA": + _taxIdType = "sa_vat"; + break; + case "SG": + _taxIdType = "sg_gst"; + break; + case "TH": + _taxIdType = "th_vat"; + break; + case "TW": + _taxIdType = "tw_vat"; + break; + case "US": + _taxIdType = "us_ein"; + break; + case "ZA": + _taxIdType = "za_vat"; + break; + default: + _taxIdType = null; + break; + } + + return _taxIdType; + } + } + } +} diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index dbf3c20b4..2bdb8f4e1 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -10,7 +10,7 @@ namespace Bit.Core.Services Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats, - bool premiumAccessAddon); + bool premiumAccessAddon, TaxInfo taxInfo); Task UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats, bool premiumAccessAddon); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, @@ -24,5 +24,7 @@ namespace Bit.Core.Services Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount); Task GetBillingAsync(ISubscriber subscriber); Task GetSubscriptionAsync(ISubscriber subscriber); + Task GetTaxInfoAsync(ISubscriber subscriber); + Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 254bc1f10..4fa4388bf 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -486,7 +486,7 @@ namespace Bit.Core.Services { await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon); + signup.PremiumAccessAddon, signup.TaxInfo); } return await SignUpAsync(organization, signup.Owner.Id, signup.OwnerKey, signup.CollectionName, true); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 5ebfa36cb..64cf0622f 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -49,7 +49,7 @@ namespace Bit.Core.Services public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, - short additionalSeats, bool premiumAccessAddon) + short additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) { var customerService = new CustomerService(); @@ -159,7 +159,21 @@ namespace Bit.Core.Services InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = stipeCustomerPaymentMethodId - } + }, + // TODO: Address info for zip code and country, optional other address info and tax ID + Address = new AddressOptions + { + Country = null, + PostalCode = null, + }, + TaxIdData = new List + { + new CustomerTaxIdDataOptions + { + Type = "", + Value = null, + }, + }, }); subCreateOptions.AddExpand("latest_invoice.payment_intent"); subCreateOptions.Customer = customer.Id; @@ -1501,6 +1515,76 @@ namespace Bit.Core.Services return subscriptionInfo; } + public async Task GetTaxInfoAsync(ISubscriber subscriber) + { + if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + { + var customerService = new CustomerService(); + var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + + if (customer == null) + { + return null; + } + + var address = customer.Address; + var taxId = customer.TaxIds?.FirstOrDefault(); + + return new TaxInfo + { + TaxIdNumber = taxId?.Value, + BillingAddressLine1 = address?.Line1, + BillingAddressLine2 = address?.Line2, + BillingAddressCity = address?.City, + BillingAddressState = address?.State, + BillingAddressPostalCode = address?.PostalCode, + BillingAddressCountry = address?.Country, + }; + } + + return null; + } + + public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) + { + if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + { + var customerService = new CustomerService(); + var customer = await customerService.UpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + { + Address = new AddressOptions + { + Line1 = taxInfo.BillingAddressLine1, + Line2 = taxInfo.BillingAddressLine2, + City = taxInfo.BillingAddressCity, + State = taxInfo.BillingAddressState, + PostalCode = taxInfo.BillingAddressPostalCode, + Country = taxInfo.BillingAddressCountry, + }, + }); + + if (!subscriber.IsUser() && customer != null) + { + var taxIdService = new TaxIdService(); + var taxId = customer.TaxIds?.FirstOrDefault(); + + if (taxId != null) + { + await taxIdService.DeleteAsync(customer.Id, taxId.Id); + } + if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) && + !string.IsNullOrWhiteSpace(taxInfo.TaxIdType)) + { + await taxIdService.CreateAsync(customer.Id, new TaxIdCreateOptions + { + Type = taxInfo.TaxIdType, + Value = taxInfo.TaxIdNumber, + }); + } + } + } + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var paymentMethodService = new PaymentMethodService();