From bad533af8e309aa79dc84bf67872cd3497bde4a4 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Thu, 2 Jan 2025 16:07:34 +0100 Subject: [PATCH 01/16] =?UTF-8?q?[PM-16611]=20Failing=20unit=20tests=20due?= =?UTF-8?q?=20to=20previous=20month=20being=20incorrectly=E2=80=A6=20(#520?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ProviderBillingControllerTests.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index d46038ae90..644303c873 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -260,13 +260,15 @@ public class ProviderBillingControllerTests var stripeAdapter = sutProvider.GetDependency(); - var (thisYear, thisMonth, _) = DateTime.UtcNow; - var daysInThisMonth = DateTime.DaysInMonth(thisYear, thisMonth); + var now = DateTime.UtcNow; + var oneMonthAgo = now.AddMonths(-1); + + var daysInThisMonth = DateTime.DaysInMonth(now.Year, now.Month); var subscription = new Subscription { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, - CurrentPeriodEnd = new DateTime(thisYear, thisMonth, daysInThisMonth), + CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth), Customer = new Customer { Address = new Address @@ -290,15 +292,14 @@ public class ProviderBillingControllerTests options.Expand.Contains("customer.tax_ids") && options.Expand.Contains("test_clock"))).Returns(subscription); - var lastMonth = thisMonth - 1; - var daysInLastMonth = DateTime.DaysInMonth(thisYear, lastMonth); + var daysInLastMonth = DateTime.DaysInMonth(oneMonthAgo.Year, oneMonthAgo.Month); var overdueInvoice = new Invoice { Id = "invoice_id", Status = "open", - Created = new DateTime(thisYear, lastMonth, 1), - PeriodEnd = new DateTime(thisYear, lastMonth, daysInLastMonth), + Created = new DateTime(oneMonthAgo.Year, oneMonthAgo.Month, 1), + PeriodEnd = new DateTime(oneMonthAgo.Year, oneMonthAgo.Month, daysInLastMonth), Attempted = true }; From 1062c6d52279eadf760843bc3ce3935f9b471aa9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jan 2025 16:13:16 +0100 Subject: [PATCH 02/16] [deps] Billing: Update Sentry.Serilog to v5 (#5182) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index e9349c4787..c5cb31d9c5 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -49,7 +49,7 @@ - + From 97e11774e3960786c991dc72ca7460b5ee3327b2 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Thu, 2 Jan 2025 20:27:53 +0100 Subject: [PATCH 03/16] [PM-13999] show estimated tax for taxable countries (#5110) --- .../Billing/TaxServiceTests.cs | 151 +++ .../Auth/Controllers/AccountsController.cs | 7 +- .../Controllers/AccountsBillingController.cs | 15 + .../Billing/Controllers/InvoicesController.cs | 42 + .../Controllers/ProviderBillingController.cs | 1 + .../Billing/Controllers/StripeController.cs | 14 +- .../Requests/TaxInformationRequestBody.cs | 2 + .../Billing/Extensions/CurrencyExtensions.cs | 33 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../PreviewIndividualInvoiceRequestModel.cs | 18 + .../PreviewOrganizationInvoiceRequestModel.cs | 37 + .../Requests/TaxInformationRequestModel.cs | 14 + .../Responses/PreviewInvoiceResponseModel.cs | 7 + src/Core/Billing/Models/PreviewInvoiceInfo.cs | 7 + .../Billing/Models/Sales/OrganizationSale.cs | 1 + src/Core/Billing/Models/TaxIdType.cs | 22 + src/Core/Billing/Models/TaxInformation.cs | 160 +--- src/Core/Billing/Services/ITaxService.cs | 22 + .../OrganizationBillingService.cs | 35 +- .../PremiumUserBillingService.cs | 15 +- .../Implementations/SubscriberService.cs | 62 +- src/Core/Billing/Services/TaxService.cs | 901 ++++++++++++++++++ src/Core/Billing/Utilities.cs | 1 + src/Core/Models/Business/TaxInfo.cs | 208 +--- src/Core/Services/IPaymentService.cs | 6 + src/Core/Services/IStripeAdapter.cs | 2 + .../Services/Implementations/StripeAdapter.cs | 12 + .../Implementations/StripePaymentService.cs | 420 +++++++- .../Services/Implementations/UserService.cs | 3 + .../Services/SubscriberServiceTests.cs | 3 +- .../Core.Test/Models/Business/TaxInfoTests.cs | 114 --- .../Services/StripePaymentServiceTests.cs | 18 +- 32 files changed, 1806 insertions(+), 548 deletions(-) create mode 100644 bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs create mode 100644 src/Api/Billing/Controllers/InvoicesController.cs create mode 100644 src/Core/Billing/Extensions/CurrencyExtensions.cs create mode 100644 src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs create mode 100644 src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs create mode 100644 src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs create mode 100644 src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs create mode 100644 src/Core/Billing/Models/PreviewInvoiceInfo.cs create mode 100644 src/Core/Billing/Models/TaxIdType.cs create mode 100644 src/Core/Billing/Services/ITaxService.cs create mode 100644 src/Core/Billing/Services/TaxService.cs delete mode 100644 test/Core.Test/Models/Business/TaxInfoTests.cs diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs new file mode 100644 index 0000000000..3995fb9de6 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs @@ -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 sutProvider) + { + var result = sutProvider.Sut.GetStripeTaxCode(country, taxId); + + Assert.Equal(expected, result); + } +} diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 1c08ce4f73..a0092357d6 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -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 }); } diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 574ac3e65e..fcb89226e7 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -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 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); + } } diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs new file mode 100644 index 0000000000..686d9b9643 --- /dev/null +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -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 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); + } +} diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index f7ddf0853e..c5de63c69b 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -119,6 +119,7 @@ public class ProviderBillingController( requestBody.Country, requestBody.PostalCode, requestBody.TaxId, + requestBody.TaxIdType, requestBody.Line1, requestBody.Line2, requestBody.City, diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index a4a974bb99..f5e8253bfa 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -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); + } } diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index c5c0fde00b..32ba2effb2 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -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, diff --git a/src/Core/Billing/Extensions/CurrencyExtensions.cs b/src/Core/Billing/Extensions/CurrencyExtensions.cs new file mode 100644 index 0000000000..cde1a7bea8 --- /dev/null +++ b/src/Core/Billing/Extensions/CurrencyExtensions.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.Billing.Extensions; + +public static class CurrencyExtensions +{ + /// + /// Converts a currency amount in major units to minor units. + /// + /// 123.99 USD returns 12399 in minor units. + public static long ToMinor(this decimal amount) + { + return Convert.ToInt64(amount * 100); + } + + /// + /// Converts a currency amount in minor units to major units. + /// + /// + /// 12399 in minor units returns 123.99 USD. + public static decimal? ToMajor(this long? amount) + { + return amount?.ToMajor(); + } + + /// + /// Converts a currency amount in minor units to major units. + /// + /// + /// 12399 in minor units returns 123.99 USD. + public static decimal ToMajor(this long amount) + { + return Convert.ToDecimal(amount) / 100; + } +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 78253f7399..e9a5d3f736 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions { public static void AddBillingOperations(this IServiceCollection services) { + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs new file mode 100644 index 0000000000..6dfb9894d5 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs @@ -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; } +} diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs new file mode 100644 index 0000000000..18d9c352d7 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs @@ -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; } +} diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs new file mode 100644 index 0000000000..9cb43645c6 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs @@ -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; } +} diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs new file mode 100644 index 0000000000..fdde7dae1e --- /dev/null +++ b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Models.Api.Responses; + +public record PreviewInvoiceResponseModel( + decimal EffectiveTaxRate, + decimal TaxableBaseAmount, + decimal TaxAmount, + decimal TotalAmount); diff --git a/src/Core/Billing/Models/PreviewInvoiceInfo.cs b/src/Core/Billing/Models/PreviewInvoiceInfo.cs new file mode 100644 index 0000000000..16a2019c20 --- /dev/null +++ b/src/Core/Billing/Models/PreviewInvoiceInfo.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Models; + +public record PreviewInvoiceInfo( + decimal EffectiveTaxRate, + decimal TaxableBaseAmount, + decimal TaxAmount, + decimal TotalAmount); diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index a19c278c68..43852bb320 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -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, diff --git a/src/Core/Billing/Models/TaxIdType.cs b/src/Core/Billing/Models/TaxIdType.cs new file mode 100644 index 0000000000..3fc246d68b --- /dev/null +++ b/src/Core/Billing/Models/TaxIdType.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace Bit.Core.Billing.Models; + +public class TaxIdType +{ + /// + /// ISO-3166-2 code for the country. + /// + public string Country { get; set; } + + /// + /// The identifier in Stripe for the tax ID type. + /// + public string Code { get; set; } + + public Regex ValidationExpression { get; set; } + + public string Description { get; set; } + + public string Example { get; set; } +} diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Models/TaxInformation.cs index 5403f94690..23ed3e5faa 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Models/TaxInformation.cs @@ -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) GetStripeOptions() - { - var address = new AddressOptions - { - Country = Country, - PostalCode = PostalCode, - Line1 = Line1, - Line2 = Line2, - City = City, - State = State - }; - - var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId) - ? new List { 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; - } - } } diff --git a/src/Core/Billing/Services/ITaxService.cs b/src/Core/Billing/Services/ITaxService.cs new file mode 100644 index 0000000000..beee113d17 --- /dev/null +++ b/src/Core/Billing/Services/ITaxService.cs @@ -0,0 +1,22 @@ +namespace Bit.Core.Billing.Services; + +public interface ITaxService +{ + /// + /// Retrieves the Stripe tax code for a given country and tax ID. + /// + /// + /// + /// + /// 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. + /// + string GetStripeTaxCode(string country, string taxId); + + /// + /// Returns true or false whether charging or storing tax is supported for the given country. + /// + /// + /// + bool IsSupported(string country); +} diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 6d9c275444..8114d5ba65 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 92c81dae1c..306ee88eaf 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -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; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 9b8f64be82..b2dca19e80 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -23,7 +23,8 @@ public class SubscriberService( IGlobalSettings globalSettings, ILogger 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, diff --git a/src/Core/Billing/Services/TaxService.cs b/src/Core/Billing/Services/TaxService.cs new file mode 100644 index 0000000000..3066be92d1 --- /dev/null +++ b/src/Core/Billing/Services/TaxService.cs @@ -0,0 +1,901 @@ +using System.Text.RegularExpressions; +using Bit.Core.Billing.Models; + +namespace Bit.Core.Billing.Services; + +public class TaxService : ITaxService +{ + /// + /// Retrieves a list of supported tax ID types for customers. + /// + /// Compiled list from Stripe + private static readonly IEnumerable _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); + } +} diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 28527af0c0..695a3b1bb4 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -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, diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs index 4424576ec9..b12c5229b3 100644 --- a/src/Core/Models/Business/TaxInfo.cs +++ b/src/Core/Models/Business/TaxInfo.cs @@ -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 { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index bf9d047029..7d0f9d3c63 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -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 RisksSubscriptionFailure(Organization organization); Task HasSecretsManagerStandalone(Organization organization); Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription); + Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); + Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); + } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 30583ef0b3..ef2e3ab766 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -31,6 +31,7 @@ public interface IStripeAdapter Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options); Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options); Task> InvoiceListAsync(StripeInvoiceListOptions options); + Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options); Task> InvoiceSearchAsync(InvoiceSearchOptions options); Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options); Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options); @@ -42,6 +43,7 @@ public interface IStripeAdapter IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); + Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null); Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options); Task TaxRateUpdateAsync(string id, Stripe.TaxRateUpdateOptions options); Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 8d18331456..f4f8efe75f 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -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 InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options) + { + return _invoiceService.CreatePreviewAsync(options); + } + public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) => (await _invoiceService.SearchAsync(options)).Data; @@ -184,6 +191,11 @@ public class StripeAdapter : IStripeAdapter return _paymentMethodService.DetachAsync(id, options); } + public Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null) + { + return _planService.GetAsync(id, options); + } + public Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options) { return _taxRateService.CreateAsync(options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 259a4eb757..ad8c7a599d 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -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 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 PreviewInvoiceAsync( + PreviewIndividualInvoiceRequestBody parameters, + string gatewayCustomerId, + string gatewaySubscriptionId) + { + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = true, + }, + Currency = "usd", + Discounts = new List(), + 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 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(), + 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( diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index cb17d6e26b..a83375271e 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -973,6 +973,9 @@ public class UserService : UserManager, IUserService, IDisposable await paymentService.CancelAndRecoverChargesAsync(user); throw; } + + + return new Tuple(string.IsNullOrWhiteSpace(paymentIntentClientSecret), paymentIntentClientSecret); } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 385b185ffe..9c25ffdc55 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1545,7 +1545,7 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); - var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1" }] } }; + var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } }; stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( 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", diff --git a/test/Core.Test/Models/Business/TaxInfoTests.cs b/test/Core.Test/Models/Business/TaxInfoTests.cs deleted file mode 100644 index 197948006e..0000000000 --- a/test/Core.Test/Models/Business/TaxInfoTests.cs +++ /dev/null @@ -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); - } -} diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index e15f07b113..35e1901a2f 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -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(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(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(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(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(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(s => From bf2bf3c13f3971e626431c9a1c2d5d324293d2db Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:37:12 -0600 Subject: [PATCH 04/16] [PM-14461] Return ProfileOrganizationResponse from subscription update (#5103) * Return ProviderOrganizationResponse from subscription update * QA: Fix SM trial seat adjustment --- .../Controllers/OrganizationsController.cs | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index ccb30c6a77..7b25114a44 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -150,7 +150,7 @@ public class OrganizationsController( [HttpPost("{id}/sm-subscription")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model) + public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model) { if (!await currentContext.EditSubscription(id)) { @@ -168,17 +168,26 @@ public class OrganizationsController( var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); + + var userId = userService.GetProperUserId(User)!.Value; + + return await GetProfileOrganizationResponseModelAsync(id, userId); } [HttpPost("{id:guid}/subscription")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSubscription(Guid id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) + public async Task PostSubscription(Guid id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) { if (!await currentContext.EditSubscription(id)) { throw new NotFoundException(); } + await organizationService.UpdateSubscription(id, model.SeatAdjustment, model.MaxAutoscaleSeats); + + var userId = userService.GetProperUserId(User)!.Value; + + return await GetProfileOrganizationResponseModelAsync(id, userId); } [HttpPost("{id:guid}/subscribe-secrets-manager")] @@ -203,13 +212,7 @@ public class OrganizationsController( await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId); - var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, - OrganizationUserStatusType.Confirmed); - - var organizationManagingActiveUser = await userService.GetOrganizationsManagingUserAsync(userId); - var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); - - return new ProfileOrganizationResponseModel(organizationDetails, organizationIdsManagingActiveUser); + return await GetProfileOrganizationResponseModelAsync(organization.Id, userId); } [HttpPost("{id:guid}/seat")] @@ -391,4 +394,19 @@ public class OrganizationsController( await organizationInstallationRepository.ReplaceAsync(organizationInstallation); } } + + private async Task GetProfileOrganizationResponseModelAsync( + Guid organizationId, + Guid userId) + { + var organizationUserDetails = await organizationUserRepository.GetDetailsByUserAsync( + userId, + organizationId, + OrganizationUserStatusType.Confirmed); + + var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId)) + .Select(o => o.Id); + + return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser); + } } From 840ff00189a8b8fd28716964eb65daa123d98009 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 2 Jan 2025 13:58:32 -0700 Subject: [PATCH 05/16] BRE-292: Sync ephemeral environment with GH workflow (#5174) * Add sync_environment call * Put callable workflow in it's own job * Switch to context for GitHub input * Set requirements and inherit secrets * Add the condition to the job * Update .github/workflows/build.yml Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com> --------- Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com> --- .github/workflows/build.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 420b9b6375..c0b598ea56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -654,6 +654,21 @@ jobs: } }) + trigger-ephemeral-environment-sync: + name: Trigger Ephemeral Environment Sync + needs: trigger-ee-updates + if: | + github.event_name == 'pull_request_target' + && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') + uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main + with: + ephemeral_env_branch: process.env.GITHUB_HEAD_REF + project: server + sync_environment: true + pull_request_number: ${{ github.event.number }} + secrets: inherit + + check-failures: name: Check for failures if: always() From c14b192e0c863334f2cf7184b91fa95957e75300 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:14:07 -0600 Subject: [PATCH 06/16] [PM-16684] Add a Pricing Client and mapping layer back to StaticStore.Plan (#5213) * Add a Pricing Client and mapping layer back to StaticStore.Plan * Run dotnet format * Temporarily remove service registration to forego any unforseen side effects * Run dotnet format --- .../Extensions/ServiceCollectionExtensions.cs | 1 + src/Core/Billing/Models/StaticStore/Plan.cs | 12 + src/Core/Billing/Pricing/IPricingClient.cs | 12 + src/Core/Billing/Pricing/PlanAdapter.cs | 232 ++++++++++++++++++ src/Core/Billing/Pricing/PricingClient.cs | 92 +++++++ .../Pricing/Protos/password-manager.proto | 92 +++++++ src/Core/Constants.cs | 1 + src/Core/Core.csproj | 11 + src/Core/Settings/GlobalSettings.cs | 2 +- 9 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 src/Core/Billing/Pricing/IPricingClient.cs create mode 100644 src/Core/Billing/Pricing/PlanAdapter.cs create mode 100644 src/Core/Billing/Pricing/PricingClient.cs create mode 100644 src/Core/Billing/Pricing/Protos/password-manager.proto diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index e9a5d3f736..9a7a4107ae 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + // services.AddSingleton(); services.AddLicenseServices(); } } diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index 15a618cca0..5dbcd7ddc4 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -8,8 +8,11 @@ public abstract record Plan public ProductTierType ProductTier { get; protected init; } public string Name { get; protected init; } public bool IsAnnual { get; protected init; } + // TODO: Move to the client public string NameLocalizationKey { get; protected init; } + // TODO: Move to the client public string DescriptionLocalizationKey { get; protected init; } + // TODO: Remove public bool CanBeUsedByBusiness { get; protected init; } public int? TrialPeriodDays { get; protected init; } public bool HasSelfHost { get; protected init; } @@ -27,7 +30,9 @@ public abstract record Plan public bool UsersGetPremium { get; protected init; } public bool HasCustomPermissions { get; protected init; } public int UpgradeSortOrder { get; protected init; } + // TODO: Move to the client public int DisplaySortOrder { get; protected init; } + // TODO: Remove public int? LegacyYear { get; protected init; } public bool Disabled { get; protected init; } public PasswordManagerPlanFeatures PasswordManager { get; protected init; } @@ -45,15 +50,19 @@ public abstract record Plan public string StripeServiceAccountPlanId { get; init; } public decimal? AdditionalPricePerServiceAccount { get; init; } public short BaseServiceAccount { get; init; } + // TODO: Unused, remove public short? MaxAdditionalServiceAccount { get; init; } public bool HasAdditionalServiceAccountOption { get; init; } // Seats public string StripeSeatPlanId { get; init; } public bool HasAdditionalSeatsOption { get; init; } + // TODO: Remove, SM is never packaged public decimal BasePrice { get; init; } public decimal SeatPrice { get; init; } + // TODO: Remove, SM is never packaged public int BaseSeats { get; init; } public short? MaxSeats { get; init; } + // TODO: Unused, remove public int? MaxAdditionalSeats { get; init; } public bool AllowSeatAutoscale { get; init; } @@ -72,8 +81,10 @@ public abstract record Plan public decimal ProviderPortalSeatPrice { get; init; } public bool AllowSeatAutoscale { get; init; } public bool HasAdditionalSeatsOption { get; init; } + // TODO: Remove, never set. public int? MaxAdditionalSeats { get; init; } public int BaseSeats { get; init; } + // TODO: Remove premium access as it's deprecated public bool HasPremiumAccessOption { get; init; } public string StripePremiumAccessPlanId { get; init; } public decimal PremiumAccessOptionPrice { get; init; } @@ -83,6 +94,7 @@ public abstract record Plan public bool HasAdditionalStorageOption { get; init; } public decimal AdditionalStoragePricePerGb { get; init; } public string StripeStoragePlanId { get; init; } + // TODO: Remove public short? MaxAdditionalStorage { get; init; } // Feature public short? MaxCollections { get; init; } diff --git a/src/Core/Billing/Pricing/IPricingClient.cs b/src/Core/Billing/Pricing/IPricingClient.cs new file mode 100644 index 0000000000..68577f1db3 --- /dev/null +++ b/src/Core/Billing/Pricing/IPricingClient.cs @@ -0,0 +1,12 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; + +#nullable enable + +namespace Bit.Core.Billing.Pricing; + +public interface IPricingClient +{ + Task GetPlan(PlanType planType); + Task> ListPlans(); +} diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs new file mode 100644 index 0000000000..b2b24d4cf9 --- /dev/null +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -0,0 +1,232 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; +using Proto.Billing.Pricing; + +#nullable enable + +namespace Bit.Core.Billing.Pricing; + +public record PlanAdapter : Plan +{ + public PlanAdapter(PlanResponse planResponse) + { + Type = ToPlanType(planResponse.LookupKey); + ProductTier = ToProductTierType(Type); + Name = planResponse.Name; + IsAnnual = !string.IsNullOrEmpty(planResponse.Cadence) && planResponse.Cadence == "annually"; + NameLocalizationKey = planResponse.AdditionalData?["nameLocalizationKey"]; + DescriptionLocalizationKey = planResponse.AdditionalData?["descriptionLocalizationKey"]; + TrialPeriodDays = planResponse.TrialPeriodDays; + HasSelfHost = HasFeature("selfHost"); + HasPolicies = HasFeature("policies"); + HasGroups = HasFeature("groups"); + HasDirectory = HasFeature("directory"); + HasEvents = HasFeature("events"); + HasTotp = HasFeature("totp"); + Has2fa = HasFeature("2fa"); + HasApi = HasFeature("api"); + HasSso = HasFeature("sso"); + HasKeyConnector = HasFeature("keyConnector"); + HasScim = HasFeature("scim"); + HasResetPassword = HasFeature("resetPassword"); + UsersGetPremium = HasFeature("usersGetPremium"); + UpgradeSortOrder = planResponse.AdditionalData != null + ? int.Parse(planResponse.AdditionalData["upgradeSortOrder"]) + : 0; + DisplaySortOrder = planResponse.AdditionalData != null + ? int.Parse(planResponse.AdditionalData["displaySortOrder"]) + : 0; + HasCustomPermissions = HasFeature("customPermissions"); + Disabled = !planResponse.Available; + PasswordManager = ToPasswordManagerPlanFeatures(planResponse); + SecretsManager = planResponse.SecretsManager != null ? ToSecretsManagerPlanFeatures(planResponse) : null; + + return; + + bool HasFeature(string lookupKey) => planResponse.Features.Any(feature => feature.LookupKey == lookupKey); + } + + #region Mappings + + private static PlanType ToPlanType(string lookupKey) + => lookupKey switch + { + "enterprise-annually" => PlanType.EnterpriseAnnually, + "enterprise-annually-2019" => PlanType.EnterpriseAnnually2019, + "enterprise-annually-2020" => PlanType.EnterpriseAnnually2020, + "enterprise-annually-2023" => PlanType.EnterpriseAnnually2023, + "enterprise-monthly" => PlanType.EnterpriseMonthly, + "enterprise-monthly-2019" => PlanType.EnterpriseMonthly2019, + "enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020, + "enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023, + "families" => PlanType.FamiliesAnnually, + "families-2019" => PlanType.FamiliesAnnually2019, + "free" => PlanType.Free, + "teams-annually" => PlanType.TeamsAnnually, + "teams-annually-2019" => PlanType.TeamsAnnually2019, + "teams-annually-2020" => PlanType.TeamsAnnually2020, + "teams-annually-2023" => PlanType.TeamsAnnually2023, + "teams-monthly" => PlanType.TeamsMonthly, + "teams-monthly-2019" => PlanType.TeamsMonthly2019, + "teams-monthly-2020" => PlanType.TeamsMonthly2020, + "teams-monthly-2023" => PlanType.TeamsMonthly2023, + "teams-starter" => PlanType.TeamsStarter, + "teams-starter-2023" => PlanType.TeamsStarter2023, + _ => throw new BillingException() // TODO: Flesh out + }; + + private static ProductTierType ToProductTierType(PlanType planType) + => planType switch + { + PlanType.Free => ProductTierType.Free, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, + _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, + _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, + _ => throw new BillingException() // TODO: Flesh out + }; + + private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanResponse planResponse) + { + var stripePlanId = GetStripePlanId(planResponse.Seats); + var stripeSeatPlanId = GetStripeSeatPlanId(planResponse.Seats); + var stripeProviderPortalSeatPlanId = planResponse.ManagedSeats?.StripePriceId; + var basePrice = GetBasePrice(planResponse.Seats); + var seatPrice = GetSeatPrice(planResponse.Seats); + var providerPortalSeatPrice = + planResponse.ManagedSeats != null ? decimal.Parse(planResponse.ManagedSeats.Price) : 0; + var scales = planResponse.Seats.KindCase switch + { + PurchasableDTO.KindOneofCase.Scalable => true, + PurchasableDTO.KindOneofCase.Packaged => planResponse.Seats.Packaged.Additional != null, + _ => false + }; + var baseSeats = GetBaseSeats(planResponse.Seats); + var maxSeats = GetMaxSeats(planResponse.Seats); + var baseStorageGb = (short?)planResponse.Storage?.Provided; + var hasAdditionalStorageOption = planResponse.Storage != null; + var stripeStoragePlanId = planResponse.Storage?.StripePriceId; + short? maxCollections = + planResponse.AdditionalData != null && + planResponse.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; + + return new PasswordManagerPlanFeatures + { + StripePlanId = stripePlanId, + StripeSeatPlanId = stripeSeatPlanId, + StripeProviderPortalSeatPlanId = stripeProviderPortalSeatPlanId, + BasePrice = basePrice, + SeatPrice = seatPrice, + ProviderPortalSeatPrice = providerPortalSeatPrice, + AllowSeatAutoscale = scales, + HasAdditionalSeatsOption = scales, + BaseSeats = baseSeats, + MaxSeats = maxSeats, + BaseStorageGb = baseStorageGb, + HasAdditionalStorageOption = hasAdditionalStorageOption, + StripeStoragePlanId = stripeStoragePlanId, + MaxCollections = maxCollections + }; + } + + private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanResponse planResponse) + { + var seats = planResponse.SecretsManager.Seats; + var serviceAccounts = planResponse.SecretsManager.ServiceAccounts; + + var maxServiceAccounts = GetMaxServiceAccounts(serviceAccounts); + var allowServiceAccountsAutoscale = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var stripeServiceAccountPlanId = GetStripeServiceAccountPlanId(serviceAccounts); + var additionalPricePerServiceAccount = GetAdditionalPricePerServiceAccount(serviceAccounts); + var baseServiceAccount = GetBaseServiceAccount(serviceAccounts); + var hasAdditionalServiceAccountOption = serviceAccounts.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var stripeSeatPlanId = GetStripeSeatPlanId(seats); + var hasAdditionalSeatsOption = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var seatPrice = GetSeatPrice(seats); + var maxSeats = GetMaxSeats(seats); + var allowSeatAutoscale = seats.KindCase == FreeOrScalableDTO.KindOneofCase.Scalable; + var maxProjects = + planResponse.AdditionalData != null && + planResponse.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0; + + return new SecretsManagerPlanFeatures + { + MaxServiceAccounts = maxServiceAccounts, + AllowServiceAccountsAutoscale = allowServiceAccountsAutoscale, + StripeServiceAccountPlanId = stripeServiceAccountPlanId, + AdditionalPricePerServiceAccount = additionalPricePerServiceAccount, + BaseServiceAccount = baseServiceAccount, + HasAdditionalServiceAccountOption = hasAdditionalServiceAccountOption, + StripeSeatPlanId = stripeSeatPlanId, + HasAdditionalSeatsOption = hasAdditionalSeatsOption, + SeatPrice = seatPrice, + MaxSeats = maxSeats, + AllowSeatAutoscale = allowSeatAutoscale, + MaxProjects = maxProjects + }; + } + + private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? null + : decimal.Parse(freeOrScalable.Scalable.Price); + + private static decimal GetBasePrice(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : decimal.Parse(purchasable.Packaged.Price); + + private static int GetBaseSeats(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? 0 : purchasable.Packaged.Quantity; + + private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase switch + { + FreeOrScalableDTO.KindOneofCase.Free => (short)freeOrScalable.Free.Quantity, + FreeOrScalableDTO.KindOneofCase.Scalable => (short)freeOrScalable.Scalable.Provided, + _ => 0 + }; + + private static short? GetMaxSeats(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Free ? null : (short)purchasable.Free.Quantity; + + private static short? GetMaxSeats(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity; + + private static short? GetMaxServiceAccounts(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Free ? null : (short)freeOrScalable.Free.Quantity; + + private static decimal GetSeatPrice(PurchasableDTO purchasable) + => purchasable.KindCase switch + { + PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional != null ? decimal.Parse(purchasable.Packaged.Additional.Price) : 0, + PurchasableDTO.KindOneofCase.Scalable => decimal.Parse(purchasable.Scalable.Price), + _ => 0 + }; + + private static decimal GetSeatPrice(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? 0 + : decimal.Parse(freeOrScalable.Scalable.Price); + + private static string? GetStripePlanId(PurchasableDTO purchasable) + => purchasable.KindCase != PurchasableDTO.KindOneofCase.Packaged ? null : purchasable.Packaged.StripePriceId; + + private static string? GetStripeSeatPlanId(PurchasableDTO purchasable) + => purchasable.KindCase switch + { + PurchasableDTO.KindOneofCase.Packaged => purchasable.Packaged.Additional?.StripePriceId, + PurchasableDTO.KindOneofCase.Scalable => purchasable.Scalable.StripePriceId, + _ => null + }; + + private static string? GetStripeSeatPlanId(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? null + : freeOrScalable.Scalable.StripePriceId; + + private static string? GetStripeServiceAccountPlanId(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.KindCase != FreeOrScalableDTO.KindOneofCase.Scalable + ? null + : freeOrScalable.Scalable.StripePriceId; + + #endregion +} diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs new file mode 100644 index 0000000000..65fc1761ad --- /dev/null +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -0,0 +1,92 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Net.Client; +using Proto.Billing.Pricing; + +#nullable enable + +namespace Bit.Core.Billing.Pricing; + +public class PricingClient( + IFeatureService featureService, + GlobalSettings globalSettings) : IPricingClient +{ + public async Task GetPlan(PlanType planType) + { + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); + + if (!usePricingService) + { + return StaticStore.GetPlan(planType); + } + + using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri); + var client = new PasswordManager.PasswordManagerClient(channel); + + var lookupKey = ToLookupKey(planType); + if (string.IsNullOrEmpty(lookupKey)) + { + return null; + } + + try + { + var response = + await client.GetPlanByLookupKeyAsync(new GetPlanByLookupKeyRequest { LookupKey = lookupKey }); + + return new PlanAdapter(response); + } + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.NotFound) + { + return null; + } + } + + public async Task> ListPlans() + { + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); + + if (!usePricingService) + { + return StaticStore.Plans.ToList(); + } + + using var channel = GrpcChannel.ForAddress(globalSettings.PricingUri); + var client = new PasswordManager.PasswordManagerClient(channel); + + var response = await client.ListPlansAsync(new Empty()); + return response.Plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList(); + } + + private static string? ToLookupKey(PlanType planType) + => planType switch + { + PlanType.EnterpriseAnnually => "enterprise-annually", + PlanType.EnterpriseAnnually2019 => "enterprise-annually-2019", + PlanType.EnterpriseAnnually2020 => "enterprise-annually-2020", + PlanType.EnterpriseAnnually2023 => "enterprise-annually-2023", + PlanType.EnterpriseMonthly => "enterprise-monthly", + PlanType.EnterpriseMonthly2019 => "enterprise-monthly-2019", + PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020", + PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023", + PlanType.FamiliesAnnually => "families", + PlanType.FamiliesAnnually2019 => "families-2019", + PlanType.Free => "free", + PlanType.TeamsAnnually => "teams-annually", + PlanType.TeamsAnnually2019 => "teams-annually-2019", + PlanType.TeamsAnnually2020 => "teams-annually-2020", + PlanType.TeamsAnnually2023 => "teams-annually-2023", + PlanType.TeamsMonthly => "teams-monthly", + PlanType.TeamsMonthly2019 => "teams-monthly-2019", + PlanType.TeamsMonthly2020 => "teams-monthly-2020", + PlanType.TeamsMonthly2023 => "teams-monthly-2023", + PlanType.TeamsStarter => "teams-starter", + PlanType.TeamsStarter2023 => "teams-starter-2023", + _ => null + }; +} diff --git a/src/Core/Billing/Pricing/Protos/password-manager.proto b/src/Core/Billing/Pricing/Protos/password-manager.proto new file mode 100644 index 0000000000..69a4c51bd1 --- /dev/null +++ b/src/Core/Billing/Pricing/Protos/password-manager.proto @@ -0,0 +1,92 @@ +syntax = "proto3"; + +option csharp_namespace = "Proto.Billing.Pricing"; + +package plans; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +service PasswordManager { + rpc GetPlanByLookupKey (GetPlanByLookupKeyRequest) returns (PlanResponse); + rpc ListPlans (google.protobuf.Empty) returns (ListPlansResponse); +} + +// Requests +message GetPlanByLookupKeyRequest { + string lookupKey = 1; +} + +// Responses +message PlanResponse { + string name = 1; + string lookupKey = 2; + string tier = 4; + optional string cadence = 6; + optional google.protobuf.Int32Value legacyYear = 8; + bool available = 9; + repeated FeatureDTO features = 10; + PurchasableDTO seats = 11; + optional ScalableDTO managedSeats = 12; + optional ScalableDTO storage = 13; + optional SecretsManagerPurchasablesDTO secretsManager = 14; + optional google.protobuf.Int32Value trialPeriodDays = 15; + repeated string canUpgradeTo = 16; + map additionalData = 17; +} + +message ListPlansResponse { + repeated PlanResponse plans = 1; +} + +// DTOs +message FeatureDTO { + string name = 1; + string lookupKey = 2; +} + +message FreeDTO { + int32 quantity = 2; + string type = 4; +} + +message PackagedDTO { + message AdditionalSeats { + string stripePriceId = 1; + string price = 2; + } + + int32 quantity = 2; + string stripePriceId = 3; + string price = 4; + optional AdditionalSeats additional = 5; + string type = 6; +} + +message ScalableDTO { + int32 provided = 2; + string stripePriceId = 6; + string price = 7; + string type = 9; +} + +message PurchasableDTO { + oneof kind { + FreeDTO free = 1; + PackagedDTO packaged = 2; + ScalableDTO scalable = 3; + } +} + +message FreeOrScalableDTO { + oneof kind { + FreeDTO free = 1; + ScalableDTO scalable = 2; + } +} + +message SecretsManagerPurchasablesDTO { + FreeOrScalableDTO seats = 1; + FreeOrScalableDTO serviceAccounts = 2; +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e0c5564ede..0b7435cf8f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -164,6 +164,7 @@ public static class FeatureFlagKeys public const string AuthenticatorSyncAndroid = "enable-authenticator-sync-android"; public const string AppReviewPrompt = "app-review-prompt"; public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; + public const string UsePricingService = "use-pricing-service"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index c5cb31d9c5..83ac307671 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -25,6 +25,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -44,6 +50,7 @@ + @@ -62,6 +69,10 @@ + + + + diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index cdbfc7cf3a..420151a34f 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -81,8 +81,8 @@ public class GlobalSettings : IGlobalSettings public virtual IDomainVerificationSettings DomainVerification { get; set; } = new DomainVerificationSettings(); public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } - public virtual bool EnableEmailVerification { get; set; } + public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) { From 3a8d10234bd1fbdc54799fa2ba3d37909fa0a373 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Fri, 3 Jan 2025 16:19:37 +0100 Subject: [PATCH 07/16] [PM-16689] Fix swagger build (#5214) --- src/Api/Utilities/ServiceCollectionExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 3d206fd887..270055be8f 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -34,6 +34,9 @@ public static class ServiceCollectionExtensions Url = new Uri("https://github.com/bitwarden/server/blob/master/LICENSE.txt") } }); + + config.CustomSchemaIds(type => type.FullName); + config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" }); config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme From 4b2030de7715418d6e6ed3ddc9fe9db59e9ddbb1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:35:28 -0500 Subject: [PATCH 08/16] [deps] BRE: Update anchore/scan-action action to v6 (#5180) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0b598ea56..899ca25f4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -307,7 +307,7 @@ jobs: - name: Scan Docker image id: container-scan - uses: anchore/scan-action@5ed195cc06065322983cae4bb31e2a751feb86fd # v5.2.0 + uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0 with: image: ${{ steps.image-tags.outputs.primary_tag }} fail-build: false From f74b94b5f71262b52ff18f875416cd661db2f8d2 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:34:29 -0500 Subject: [PATCH 09/16] [PM-16700] Handling nulls in UserLicenseClaimsFactory (#5217) * Handling nulls in UserLicenseClaimsFactory * Only setting Token if the flag is enabled --- .../UserLicenseClaimsFactory.cs | 30 ++++++++++++++----- .../Services/Implementations/UserService.cs | 3 ++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs index 28c779c3d6..3b7b275469 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs @@ -12,26 +12,42 @@ public class UserLicenseClaimsFactory : ILicenseClaimsFactory { var subscriptionInfo = licenseContext.SubscriptionInfo; - var expires = subscriptionInfo.UpcomingInvoice?.Date?.AddDays(7) ?? entity.PremiumExpirationDate?.AddDays(7); - var refresh = subscriptionInfo.UpcomingInvoice?.Date ?? entity.PremiumExpirationDate; - var trial = (subscriptionInfo.Subscription?.TrialEndDate.HasValue ?? false) && + var expires = subscriptionInfo?.UpcomingInvoice?.Date?.AddDays(7) ?? entity.PremiumExpirationDate?.AddDays(7); + var refresh = subscriptionInfo?.UpcomingInvoice?.Date ?? entity.PremiumExpirationDate; + var trial = (subscriptionInfo?.Subscription?.TrialEndDate.HasValue ?? false) && subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow; var claims = new List { new(nameof(UserLicenseConstants.LicenseType), LicenseType.User.ToString()), - new(nameof(UserLicenseConstants.LicenseKey), entity.LicenseKey), new(nameof(UserLicenseConstants.Id), entity.Id.ToString()), new(nameof(UserLicenseConstants.Name), entity.Name), new(nameof(UserLicenseConstants.Email), entity.Email), new(nameof(UserLicenseConstants.Premium), entity.Premium.ToString()), - new(nameof(UserLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()), new(nameof(UserLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)), - new(nameof(UserLicenseConstants.Expires), expires.ToString()), - new(nameof(UserLicenseConstants.Refresh), refresh.ToString()), new(nameof(UserLicenseConstants.Trial), trial.ToString()), }; + if (entity.LicenseKey is not null) + { + claims.Add(new(nameof(UserLicenseConstants.LicenseKey), entity.LicenseKey)); + } + + if (entity.MaxStorageGb is not null) + { + claims.Add(new(nameof(UserLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString())); + } + + if (expires is not null) + { + claims.Add(new(nameof(UserLicenseConstants.Expires), expires.ToString())); + } + + if (refresh is not null) + { + claims.Add(new(nameof(UserLicenseConstants.Refresh), refresh.ToString())); + } + return Task.FromResult(claims); } } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index a83375271e..281a14bc36 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1143,7 +1143,10 @@ public class UserService : UserManager, IUserService, IDisposable ? new UserLicense(user, _licenseService) : new UserLicense(user, subscriptionInfo, _licenseService); + if (_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor)) + { userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo); + } return userLicense; } From 4871f0b9562f63a741a5024f5c1d2565b68a5bb5 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 3 Jan 2025 16:00:52 -0500 Subject: [PATCH 10/16] Ran `dotnet format` (#5218) * Ran `dotnet format` * Re-added usings --- src/Core/Services/Implementations/UserService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 281a14bc36..346d77aad4 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1145,7 +1145,7 @@ public class UserService : UserManager, IUserService, IDisposable if (_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor)) { - userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo); + userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo); } return userLicense; From 066cd4655d204f06c0b47e5183f8f7e5b0e4c237 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 3 Jan 2025 17:33:57 -0500 Subject: [PATCH 11/16] [deps] BRE: Update codecov/codecov-action action to v5 (#5071) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f3b9871bc..bc04137f9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: fail-on-error: true - name: Upload to codecov.io - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 + uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 if: ${{ needs.check-test-secrets.outputs.available == 'true' }} env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From ff846280e507ca75899142f35048aec54e8f206e Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Sun, 5 Jan 2025 11:14:38 +0100 Subject: [PATCH 12/16] [PM-16682] Provider setup tax information is not saved (#5211) --- .../Billing/ProviderBillingService.cs | 31 ++++++++++++++----- .../Billing/ProviderBillingServiceTests.cs | 29 +++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index a6bf62871f..57349042d1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -32,7 +32,8 @@ public class ProviderBillingService( IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService) : IProviderBillingService + ISubscriberService subscriberService, + ITaxService taxService) : IProviderBillingService { public async Task ChangePlan(ChangeProviderPlanCommand command) { @@ -335,14 +336,30 @@ public class ProviderBillingService( Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } - }, - TaxIdData = taxInfo.HasTaxId ? - [ - new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber } - ] - : null + } }; + if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) + { + var 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"); + } + + customerCreateOptions.TaxIdData = taxInfo.HasTaxId + ? + [ + new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } + ] + : null; + } + try { return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index 881a984554..3739603a2d 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -746,6 +746,12 @@ public class ProviderBillingServiceTests { provider.Name = "MSP"; + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + taxInfo.BillingAddressCountry = "AD"; var stripeAdapter = sutProvider.GetDependency(); @@ -777,6 +783,29 @@ public class ProviderBillingServiceTests Assert.Equivalent(expected, actual); } + [Theory, BitAutoData] + public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + taxInfo.BillingAddressCountry = "AD"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns((string)null); + + var actual = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetupCustomer(provider, taxInfo)); + + Assert.IsType(actual); + Assert.Equal("billingTaxIdTypeInferenceError", actual.Message); + } + #endregion #region SetupSubscription From 03feb038b795be0f1a26f174689b094d1f968d49 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 6 Jan 2025 08:06:09 -0600 Subject: [PATCH 13/16] Changing the name of the menu item. (#5216) --- src/Admin/Views/Shared/_Layout.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Admin/Views/Shared/_Layout.cshtml b/src/Admin/Views/Shared/_Layout.cshtml index b1f0a24420..939eb86b86 100644 --- a/src/Admin/Views/Shared/_Layout.cshtml +++ b/src/Admin/Views/Shared/_Layout.cshtml @@ -92,7 +92,7 @@ @if (canPromoteAdmin) { - Promote Admin + Promote Organization Admin } @if (canPromoteProviderServiceUser) From 217b86ba9e81e5e2f3f68f39fffa871bedccd890 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 6 Jan 2025 10:34:52 -0600 Subject: [PATCH 14/16] Modified view and models to pull Provider Type from the provider table for The ProviderUserOrganizationDetailsViewQuery (#5215) --- ...rofileProviderOrganizationResponseModel.cs | 1 + .../ProviderUserOrganizationDetails.cs | 1 + ...roviderUserOrganizationDetailsViewQuery.cs | 1 + ...derUserProviderOrganizationDetailsView.sql | 3 +- ...ProviderOrgDetailsView_AddProviderType.sql | 49 +++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 util/Migrator/DbScripts/2025-01-03_00_ProviderUserProviderOrgDetailsView_AddProviderType.sql diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 7227d7a11a..211476dca1 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -43,6 +43,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo UserId = organization.UserId; ProviderId = organization.ProviderId; ProviderName = organization.ProviderName; + ProviderType = organization.ProviderType; ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionDeletion = organization.LimitCollectionDeletion; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index f37cc644d4..bd5592edfc 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -44,4 +44,5 @@ public class ProviderUserOrganizationDetails public bool LimitCollectionDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs index 7d9974d117..3f3d3d389e 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs @@ -48,6 +48,7 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery Date: Mon, 6 Jan 2025 12:10:53 -0500 Subject: [PATCH 15/16] chore: move `Installation` and `Push` to platform's domain folders (#5085) * chore: set up a `CODEOWNERS` space for platform * chore: move sql objects for `Installation` to platform's domain * chore: move `Installation` and `PushRelay` code to platform's domain --- .github/CODEOWNERS | 1 + src/Admin/Controllers/ToolsController.cs | 1 + .../Controllers/InstallationsController.cs | 8 +++----- .../Models}/InstallationRequestModel.cs | 4 ++-- .../Models}/InstallationResponseModel.cs | 6 +++--- .../Push}/Controllers/PushController.cs | 8 ++++++-- .../PaymentSucceededHandler.cs | 1 + .../SubscriptionUpdatedHandler.cs | 1 + .../UpdateOrganizationAuthRequestCommand.cs | 1 + ...teManagedOrganizationUserAccountCommand.cs | 1 + .../RemoveOrganizationUserCommand.cs | 1 + .../CloudOrganizationSignUpCommand.cs | 1 + .../Implementations/OrganizationService.cs | 1 + .../Implementations/AuthRequestService.cs | 1 + .../TdeOffboardingPasswordCommand.cs | 1 + .../RegenerateUserAsymmetricKeysCommand.cs | 2 +- .../Implementations/RotateUserKeyCommand.cs | 1 + .../NotificationHubPushNotificationService.cs | 2 +- .../NotificationHubPushRegistrationService.cs | 2 +- .../Cloud/CloudGetOrganizationLicenseQuery.cs | 2 +- .../Installations}/Entities/Installation.cs | 7 ++++++- .../Repositories/IInstallationRepository.cs | 19 +++++++++++++++++++ .../AzureQueuePushNotificationService.cs | 2 +- .../Services/IPushNotificationService.cs | 2 +- .../Services/IPushRegistrationService.cs | 2 +- .../MultiServicePushNotificationService.cs | 2 +- .../Services}/NoopPushNotificationService.cs | 2 +- .../Services}/NoopPushRegistrationService.cs | 2 +- ...NotificationsApiPushNotificationService.cs | 4 +++- .../Services}/RelayPushNotificationService.cs | 3 ++- .../Services}/RelayPushRegistrationService.cs | 3 ++- .../Repositories/IInstallationRepository.cs | 9 --------- .../Services/Implementations/DeviceService.cs | 1 + .../Services/Implementations/UserService.cs | 1 + .../Services/Implementations/SendService.cs | 1 + .../Services/Implementations/CipherService.cs | 1 + src/Identity/IdentityServer/ClientStore.cs | 1 + .../DapperServiceCollectionExtensions.cs | 2 ++ .../Repositories/InstallationRepository.cs | 14 +++++++++++--- .../Models/OrganizationInstallation.cs | 2 +- ...ityFrameworkServiceCollectionExtensions.cs | 2 ++ .../Installations}/Models/Installation.cs | 9 +++++---- .../Repositories/InstallationRepository.cs | 16 ++++++++++++++++ .../Repositories/DatabaseContext.cs | 1 + .../Repositories/InstallationRepository.cs | 15 --------------- .../Utilities/ServiceCollectionExtensions.cs | 2 ++ .../Stored Procedures/Installation_Create.sql | 0 .../Installation_DeleteById.sql | 0 .../Installation_ReadById.sql | 0 .../Stored Procedures/Installation_Update.sql | 0 .../dbo/Tables/Installation.sql | 0 .../dbo/Views/InstallationView.sql | 0 ...dateOrganizationAuthRequestCommandTests.cs | 1 + .../Auth/Services/AuthRequestServiceTests.cs | 1 + ...egenerateUserAsymmetricKeysCommandTests.cs | 2 +- .../UserKey/RotateUserKeyCommandTests.cs | 1 + ...ficationHubPushNotificationServiceTests.cs | 2 +- .../CloudGetOrganizationLicenseQueryTests.cs | 3 +-- .../AzureQueuePushNotificationServiceTests.cs | 5 ++--- ...ultiServicePushNotificationServiceTests.cs | 3 +-- ...icationsApiPushNotificationServiceTests.cs | 5 ++--- .../RelayPushNotificationServiceTests.cs | 3 +-- .../RelayPushRegistrationServiceTests.cs | 5 ++--- test/Core.Test/Services/DeviceServiceTests.cs | 1 + test/Core.Test/Services/UserServiceTests.cs | 1 + .../Tools/Services/SendServiceTests.cs | 1 + .../Vault/Services/CipherServiceTests.cs | 1 + .../Endpoints/IdentityServerTests.cs | 3 ++- .../EntityFrameworkRepositoryFixtures.cs | 1 + .../AutoFixture/InstallationFixtures.cs | 10 +++++----- .../Repositories}/InstallationCompare.cs | 4 ++-- .../InstallationRepositoryTests.cs | 19 +++++++++---------- .../Factories/WebApplicationFactoryBase.cs | 2 ++ 73 files changed, 152 insertions(+), 93 deletions(-) rename src/Api/{ => Platform/Installations}/Controllers/InstallationsController.cs (88%) rename src/Api/{Models/Request => Platform/Installations/Models}/InstallationRequestModel.cs (84%) rename src/Api/{Models/Response => Platform/Installations/Models}/InstallationResponseModel.cs (78%) rename src/Api/{ => Platform/Push}/Controllers/PushController.cs (94%) rename src/Core/{ => Platform/Installations}/Entities/Installation.cs (68%) create mode 100644 src/Core/Platform/Installations/Repositories/IInstallationRepository.cs rename src/Core/{Services/Implementations => Platform/Push/Services}/AzureQueuePushNotificationService.cs (99%) rename src/Core/{ => Platform/Push}/Services/IPushNotificationService.cs (97%) rename src/Core/{ => Platform/Push}/Services/IPushRegistrationService.cs (93%) rename src/Core/{Services/Implementations => Platform/Push/Services}/MultiServicePushNotificationService.cs (99%) rename src/Core/{Services/NoopImplementations => Platform/Push/Services}/NoopPushNotificationService.cs (98%) rename src/Core/{Services/NoopImplementations => Platform/Push/Services}/NoopPushRegistrationService.cs (94%) rename src/Core/{Services/Implementations => Platform/Push/Services}/NotificationsApiPushNotificationService.cs (97%) rename src/Core/{Services/Implementations => Platform/Push/Services}/RelayPushNotificationService.cs (99%) rename src/Core/{Services/Implementations => Platform/Push/Services}/RelayPushRegistrationService.cs (96%) delete mode 100644 src/Core/Repositories/IInstallationRepository.cs rename src/Infrastructure.Dapper/{ => Platform/Installations}/Repositories/InstallationRepository.cs (53%) rename src/Infrastructure.EntityFramework/{ => Platform/Installations}/Models/Installation.cs (70%) create mode 100644 src/Infrastructure.EntityFramework/Platform/Installations/Repositories/InstallationRepository.cs delete mode 100644 src/Infrastructure.EntityFramework/Repositories/InstallationRepository.cs rename src/Sql/{ => Platform}/dbo/Stored Procedures/Installation_Create.sql (100%) rename src/Sql/{ => Platform}/dbo/Stored Procedures/Installation_DeleteById.sql (100%) rename src/Sql/{ => Platform}/dbo/Stored Procedures/Installation_ReadById.sql (100%) rename src/Sql/{ => Platform}/dbo/Stored Procedures/Installation_Update.sql (100%) rename src/Sql/{ => Platform}/dbo/Tables/Installation.sql (100%) rename src/Sql/{ => Platform}/dbo/Views/InstallationView.sql (100%) rename test/Core.Test/{ => Platform/Push}/Services/AzureQueuePushNotificationServiceTests.cs (90%) rename test/Core.Test/{ => Platform/Push}/Services/MultiServicePushNotificationServiceTests.cs (96%) rename test/Core.Test/{ => Platform/Push}/Services/NotificationsApiPushNotificationServiceTests.cs (93%) rename test/Core.Test/{ => Platform/Push}/Services/RelayPushNotificationServiceTests.cs (95%) rename test/Core.Test/{ => Platform/Push}/Services/RelayPushRegistrationServiceTests.cs (91%) rename test/Infrastructure.EFIntegration.Test/{Repositories/EqualityComparers => Platform/Installations/Repositories}/InstallationCompare.cs (78%) rename test/Infrastructure.EFIntegration.Test/{ => Platform/Installations}/Repositories/InstallationRepositoryTests.cs (64%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9784e1f9ab..11e79590f2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev .github/workflows/repository-management.yml @bitwarden/team-platform-dev .github/workflows/test-database.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev +**/*Platform* @bitwarden/team-platform-dev # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json diff --git a/src/Admin/Controllers/ToolsController.cs b/src/Admin/Controllers/ToolsController.cs index ea91d01cb8..45319cf79c 100644 --- a/src/Admin/Controllers/ToolsController.cs +++ b/src/Admin/Controllers/ToolsController.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Models.BitStripe; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Api/Controllers/InstallationsController.cs b/src/Api/Platform/Installations/Controllers/InstallationsController.cs similarity index 88% rename from src/Api/Controllers/InstallationsController.cs rename to src/Api/Platform/Installations/Controllers/InstallationsController.cs index a2eeebab37..a9ba4e6c02 100644 --- a/src/Api/Controllers/InstallationsController.cs +++ b/src/Api/Platform/Installations/Controllers/InstallationsController.cs @@ -1,12 +1,10 @@ -using Bit.Api.Models.Request; -using Bit.Api.Models.Response; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Installations; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Platform.Installations; [Route("installations")] [SelfHosted(NotSelfHostedOnly = true)] diff --git a/src/Api/Models/Request/InstallationRequestModel.cs b/src/Api/Platform/Installations/Models/InstallationRequestModel.cs similarity index 84% rename from src/Api/Models/Request/InstallationRequestModel.cs rename to src/Api/Platform/Installations/Models/InstallationRequestModel.cs index 65b542e62e..242701a66f 100644 --- a/src/Api/Models/Request/InstallationRequestModel.cs +++ b/src/Api/Platform/Installations/Models/InstallationRequestModel.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Entities; +using Bit.Core.Platform.Installations; using Bit.Core.Utilities; -namespace Bit.Api.Models.Request; +namespace Bit.Api.Platform.Installations; public class InstallationRequestModel { diff --git a/src/Api/Models/Response/InstallationResponseModel.cs b/src/Api/Platform/Installations/Models/InstallationResponseModel.cs similarity index 78% rename from src/Api/Models/Response/InstallationResponseModel.cs rename to src/Api/Platform/Installations/Models/InstallationResponseModel.cs index 2fdc55d847..0be5795275 100644 --- a/src/Api/Models/Response/InstallationResponseModel.cs +++ b/src/Api/Platform/Installations/Models/InstallationResponseModel.cs @@ -1,7 +1,7 @@ -using Bit.Core.Entities; -using Bit.Core.Models.Api; +using Bit.Core.Models.Api; +using Bit.Core.Platform.Installations; -namespace Bit.Api.Models.Response; +namespace Bit.Api.Platform.Installations; public class InstallationResponseModel : ResponseModel { diff --git a/src/Api/Controllers/PushController.cs b/src/Api/Platform/Push/Controllers/PushController.cs similarity index 94% rename from src/Api/Controllers/PushController.cs rename to src/Api/Platform/Push/Controllers/PushController.cs index 3839805106..4b9f1c3e11 100644 --- a/src/Api/Controllers/PushController.cs +++ b/src/Api/Platform/Push/Controllers/PushController.cs @@ -1,14 +1,18 @@ using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api; -using Bit.Core.Services; +using Bit.Core.Platform.Push; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Platform.Push; +/// +/// Routes for push relay: functionality that facilitates communication +/// between self hosted organizations and Bitwarden cloud. +/// [Route("push")] [Authorize("Push")] [SelfHosted(NotSelfHostedOnly = true)] diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 49578187f9..b16baea52e 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Context; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Enums; diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index d49b22b7fb..6b4fef43d1 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,5 +1,6 @@ using Bit.Billing.Constants; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs b/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs index 407ca61c4d..af966a6e16 100644 --- a/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs +++ b/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Services; using Bit.Core.Enums; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs index cb7e2a6250..010f5de9bf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index e45f109df1..9375a231ec 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 3eb4d35ef1..df841adf42 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -11,6 +11,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.StaticStore; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Enums; diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1cf22b23ad..9d178697ac 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -27,6 +27,7 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Mail; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Services/Implementations/AuthRequestService.cs b/src/Core/Auth/Services/Implementations/AuthRequestService.cs index a27112425b..f83c5de1f6 100644 --- a/src/Core/Auth/Services/Implementations/AuthRequestService.cs +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -7,6 +7,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index d33db18e44..8ef586ab51 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; diff --git a/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs index a54223f685..9b93d44182 100644 --- a/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs +++ b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs @@ -8,7 +8,7 @@ using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; -using Bit.Core.Services; +using Bit.Core.Platform.Push; using Microsoft.Extensions.Logging; namespace Bit.Core.KeyManagement.Commands; diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs index 68b2c60293..8cece5f762 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Repositories; diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs index 7438e812e0..67faff619d 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs @@ -6,8 +6,8 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; diff --git a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs b/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs index 123152c01c..180b2b641b 100644 --- a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs @@ -1,7 +1,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs index d7782fcd98..53050c7824 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs @@ -4,7 +4,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; -using Bit.Core.Repositories; +using Bit.Core.Platform.Installations; using Bit.Core.Services; namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; diff --git a/src/Core/Entities/Installation.cs b/src/Core/Platform/Installations/Entities/Installation.cs similarity index 68% rename from src/Core/Entities/Installation.cs rename to src/Core/Platform/Installations/Entities/Installation.cs index ff30236d3d..63aa5d1e24 100644 --- a/src/Core/Entities/Installation.cs +++ b/src/Core/Platform/Installations/Entities/Installation.cs @@ -1,10 +1,15 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; using Bit.Core.Utilities; #nullable enable -namespace Bit.Core.Entities; +namespace Bit.Core.Platform.Installations; +/// +/// The base entity for the SQL table `dbo.Installation`. Used to store +/// information pertinent to self hosted Bitwarden installations. +/// public class Installation : ITableObject { public Guid Id { get; set; } diff --git a/src/Core/Platform/Installations/Repositories/IInstallationRepository.cs b/src/Core/Platform/Installations/Repositories/IInstallationRepository.cs new file mode 100644 index 0000000000..5303eb04e6 --- /dev/null +++ b/src/Core/Platform/Installations/Repositories/IInstallationRepository.cs @@ -0,0 +1,19 @@ +using Bit.Core.Repositories; + +#nullable enable + +namespace Bit.Core.Platform.Installations; + +/// +/// The CRUD repository interface for communicating with `dbo.Installation`, +/// which is used to store information pertinent to self-hosted +/// installations. +/// +/// +/// This interface is implemented by `InstallationRepository` in the Dapper +/// and Entity Framework projects. +/// +/// +public interface IInstallationRepository : IRepository +{ +} diff --git a/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs similarity index 99% rename from src/Core/Services/Implementations/AzureQueuePushNotificationService.cs rename to src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs index 3daadebf3a..332b322be6 100644 --- a/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs +++ b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs @@ -11,7 +11,7 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push.Internal; public class AzureQueuePushNotificationService : IPushNotificationService { diff --git a/src/Core/Services/IPushNotificationService.cs b/src/Core/Platform/Push/Services/IPushNotificationService.cs similarity index 97% rename from src/Core/Services/IPushNotificationService.cs rename to src/Core/Platform/Push/Services/IPushNotificationService.cs index 6e2e47e27f..986b54b6d9 100644 --- a/src/Core/Services/IPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/IPushNotificationService.cs @@ -4,7 +4,7 @@ using Bit.Core.Enums; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push; public interface IPushNotificationService { diff --git a/src/Core/Services/IPushRegistrationService.cs b/src/Core/Platform/Push/Services/IPushRegistrationService.cs similarity index 93% rename from src/Core/Services/IPushRegistrationService.cs rename to src/Core/Platform/Push/Services/IPushRegistrationService.cs index 985246de0c..482e7ae1c4 100644 --- a/src/Core/Services/IPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/IPushRegistrationService.cs @@ -1,6 +1,6 @@ using Bit.Core.Enums; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push; public interface IPushRegistrationService { diff --git a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs b/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs similarity index 99% rename from src/Core/Services/Implementations/MultiServicePushNotificationService.cs rename to src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs index 185a11adbb..a291aa037f 100644 --- a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs +++ b/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs @@ -7,7 +7,7 @@ using Bit.Core.Vault.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push.Internal; public class MultiServicePushNotificationService : IPushNotificationService { diff --git a/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs b/src/Core/Platform/Push/Services/NoopPushNotificationService.cs similarity index 98% rename from src/Core/Services/NoopImplementations/NoopPushNotificationService.cs rename to src/Core/Platform/Push/Services/NoopPushNotificationService.cs index b5e2616220..6d5fbfd9a4 100644 --- a/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/NoopPushNotificationService.cs @@ -4,7 +4,7 @@ using Bit.Core.Enums; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push.Internal; public class NoopPushNotificationService : IPushNotificationService { diff --git a/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs b/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs similarity index 94% rename from src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs rename to src/Core/Platform/Push/Services/NoopPushRegistrationService.cs index f6279c9467..6d1716a6ce 100644 --- a/src/Core/Services/NoopImplementations/NoopPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs @@ -1,6 +1,6 @@ using Bit.Core.Enums; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push.Internal; public class NoopPushRegistrationService : IPushRegistrationService { diff --git a/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs similarity index 97% rename from src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs rename to src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs index feec75fbe0..adf6d829e7 100644 --- a/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs @@ -3,13 +3,15 @@ using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +// This service is not in the `Internal` namespace because it has direct external references. +namespace Bit.Core.Platform.Push; public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushNotificationService { diff --git a/src/Core/Services/Implementations/RelayPushNotificationService.cs b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs similarity index 99% rename from src/Core/Services/Implementations/RelayPushNotificationService.cs rename to src/Core/Platform/Push/Services/RelayPushNotificationService.cs index d725296779..93db0c0c5b 100644 --- a/src/Core/Services/Implementations/RelayPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs @@ -6,13 +6,14 @@ using Bit.Core.IdentityServer; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push.Internal; public class RelayPushNotificationService : BaseIdentityClientService, IPushNotificationService { diff --git a/src/Core/Services/Implementations/RelayPushRegistrationService.cs b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs similarity index 96% rename from src/Core/Services/Implementations/RelayPushRegistrationService.cs rename to src/Core/Platform/Push/Services/RelayPushRegistrationService.cs index d0f7736e98..a42a831266 100644 --- a/src/Core/Services/Implementations/RelayPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs @@ -1,10 +1,11 @@ using Bit.Core.Enums; using Bit.Core.IdentityServer; using Bit.Core.Models.Api; +using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Push.Internal; public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService { diff --git a/src/Core/Repositories/IInstallationRepository.cs b/src/Core/Repositories/IInstallationRepository.cs deleted file mode 100644 index f9c7d85edf..0000000000 --- a/src/Core/Repositories/IInstallationRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Bit.Core.Entities; - -#nullable enable - -namespace Bit.Core.Repositories; - -public interface IInstallationRepository : IRepository -{ -} diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs index 638e4c5e07..afbc574417 100644 --- a/src/Core/Services/Implementations/DeviceService.cs +++ b/src/Core/Services/Implementations/DeviceService.cs @@ -2,6 +2,7 @@ using Bit.Core.Auth.Utilities; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; namespace Bit.Core.Services; diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 346d77aad4..4d2cb45d93 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -18,6 +18,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Tokens; diff --git a/src/Core/Tools/Services/Implementations/SendService.cs b/src/Core/Tools/Services/Implementations/SendService.cs index fad941362b..918379d7a5 100644 --- a/src/Core/Tools/Services/Implementations/SendService.cs +++ b/src/Core/Tools/Services/Implementations/SendService.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index d6947b5412..d6806bd115 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -5,6 +5,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Identity/IdentityServer/ClientStore.cs b/src/Identity/IdentityServer/ClientStore.cs index 3f1c1c2fd4..c204e364ce 100644 --- a/src/Identity/IdentityServer/ClientStore.cs +++ b/src/Identity/IdentityServer/ClientStore.cs @@ -5,6 +5,7 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Identity; using Bit.Core.IdentityServer; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 834f681d28..93814a6d7f 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Repositories; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Tools.Repositories; @@ -12,6 +13,7 @@ using Bit.Infrastructure.Dapper.Auth.Repositories; using Bit.Infrastructure.Dapper.Billing.Repositories; using Bit.Infrastructure.Dapper.KeyManagement.Repositories; using Bit.Infrastructure.Dapper.NotificationCenter.Repositories; +using Bit.Infrastructure.Dapper.Platform; using Bit.Infrastructure.Dapper.Repositories; using Bit.Infrastructure.Dapper.SecretsManager.Repositories; using Bit.Infrastructure.Dapper.Tools.Repositories; diff --git a/src/Infrastructure.Dapper/Repositories/InstallationRepository.cs b/src/Infrastructure.Dapper/Platform/Installations/Repositories/InstallationRepository.cs similarity index 53% rename from src/Infrastructure.Dapper/Repositories/InstallationRepository.cs rename to src/Infrastructure.Dapper/Platform/Installations/Repositories/InstallationRepository.cs index ae10932699..41ca18950a 100644 --- a/src/Infrastructure.Dapper/Repositories/InstallationRepository.cs +++ b/src/Infrastructure.Dapper/Platform/Installations/Repositories/InstallationRepository.cs @@ -1,11 +1,19 @@ -using Bit.Core.Entities; -using Bit.Core.Repositories; +using Bit.Core.Platform.Installations; using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; #nullable enable -namespace Bit.Infrastructure.Dapper.Repositories; +namespace Bit.Infrastructure.Dapper.Platform; +/// +/// The CRUD repository for communicating with `dbo.Installation`. +/// +/// +/// If referencing: you probably want the interface `IInstallationRepository` +/// instead of directly calling this class. +/// +/// public class InstallationRepository : Repository, IInstallationRepository { public InstallationRepository(GlobalSettings globalSettings) diff --git a/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs b/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs index 2f00768206..c59a2accba 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs @@ -1,6 +1,6 @@ using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Platform; namespace Bit.Infrastructure.EntityFramework.Billing.Models; diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index b2eefe4523..f3b96c201b 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Bit.Core.Billing.Repositories; using Bit.Core.Enums; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Tools.Repositories; @@ -13,6 +14,7 @@ using Bit.Infrastructure.EntityFramework.Auth.Repositories; using Bit.Infrastructure.EntityFramework.Billing.Repositories; using Bit.Infrastructure.EntityFramework.KeyManagement.Repositories; using Bit.Infrastructure.EntityFramework.NotificationCenter.Repositories; +using Bit.Infrastructure.EntityFramework.Platform; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.SecretsManager.Repositories; using Bit.Infrastructure.EntityFramework.Tools.Repositories; diff --git a/src/Infrastructure.EntityFramework/Models/Installation.cs b/src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs similarity index 70% rename from src/Infrastructure.EntityFramework/Models/Installation.cs rename to src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs index c38680a23c..96b60a39ed 100644 --- a/src/Infrastructure.EntityFramework/Models/Installation.cs +++ b/src/Infrastructure.EntityFramework/Platform/Installations/Models/Installation.cs @@ -1,8 +1,9 @@ using AutoMapper; +using C = Bit.Core.Platform.Installations; -namespace Bit.Infrastructure.EntityFramework.Models; +namespace Bit.Infrastructure.EntityFramework.Platform; -public class Installation : Core.Entities.Installation +public class Installation : C.Installation { // Shadow property - to be introduced by https://bitwarden.atlassian.net/browse/PM-11129 // This isn't a value or entity used by self hosted servers, but it's @@ -14,10 +15,10 @@ public class InstallationMapperProfile : Profile { public InstallationMapperProfile() { - CreateMap() + CreateMap() // Shadow property - to be introduced by https://bitwarden.atlassian.net/browse/PM-11129 .ForMember(i => i.LastActivityDate, opt => opt.Ignore()) .ReverseMap(); - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Platform/Installations/Repositories/InstallationRepository.cs b/src/Infrastructure.EntityFramework/Platform/Installations/Repositories/InstallationRepository.cs new file mode 100644 index 0000000000..255cc76cf2 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Platform/Installations/Repositories/InstallationRepository.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; +using C = Bit.Core.Platform.Installations; +using Ef = Bit.Infrastructure.EntityFramework.Platform; + +#nullable enable + +namespace Bit.Infrastructure.EntityFramework.Platform; + +public class InstallationRepository : Repository, C.IInstallationRepository +{ + public InstallationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Installations) + { } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index 24ef2ab269..dd1b97b4f2 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -6,6 +6,7 @@ using Bit.Infrastructure.EntityFramework.Billing.Models; using Bit.Infrastructure.EntityFramework.Converters; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.NotificationCenter.Models; +using Bit.Infrastructure.EntityFramework.Platform; using Bit.Infrastructure.EntityFramework.SecretsManager.Models; using Bit.Infrastructure.EntityFramework.Tools.Models; using Bit.Infrastructure.EntityFramework.Vault.Models; diff --git a/src/Infrastructure.EntityFramework/Repositories/InstallationRepository.cs b/src/Infrastructure.EntityFramework/Repositories/InstallationRepository.cs deleted file mode 100644 index 64777a384b..0000000000 --- a/src/Infrastructure.EntityFramework/Repositories/InstallationRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using AutoMapper; -using Bit.Core.Repositories; -using Bit.Infrastructure.EntityFramework.Models; -using Microsoft.Extensions.DependencyInjection; - -#nullable enable - -namespace Bit.Infrastructure.EntityFramework.Repositories; - -public class InstallationRepository : Repository, IInstallationRepository -{ - public InstallationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) - : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Installations) - { } -} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 85bd0301c3..46f8293d3e 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -30,6 +30,8 @@ using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Resources; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Sql/dbo/Stored Procedures/Installation_Create.sql b/src/Sql/Platform/dbo/Stored Procedures/Installation_Create.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Installation_Create.sql rename to src/Sql/Platform/dbo/Stored Procedures/Installation_Create.sql diff --git a/src/Sql/dbo/Stored Procedures/Installation_DeleteById.sql b/src/Sql/Platform/dbo/Stored Procedures/Installation_DeleteById.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Installation_DeleteById.sql rename to src/Sql/Platform/dbo/Stored Procedures/Installation_DeleteById.sql diff --git a/src/Sql/dbo/Stored Procedures/Installation_ReadById.sql b/src/Sql/Platform/dbo/Stored Procedures/Installation_ReadById.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Installation_ReadById.sql rename to src/Sql/Platform/dbo/Stored Procedures/Installation_ReadById.sql diff --git a/src/Sql/dbo/Stored Procedures/Installation_Update.sql b/src/Sql/Platform/dbo/Stored Procedures/Installation_Update.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Installation_Update.sql rename to src/Sql/Platform/dbo/Stored Procedures/Installation_Update.sql diff --git a/src/Sql/dbo/Tables/Installation.sql b/src/Sql/Platform/dbo/Tables/Installation.sql similarity index 100% rename from src/Sql/dbo/Tables/Installation.sql rename to src/Sql/Platform/dbo/Tables/Installation.sql diff --git a/src/Sql/dbo/Views/InstallationView.sql b/src/Sql/Platform/dbo/Views/InstallationView.sql similarity index 100% rename from src/Sql/dbo/Views/InstallationView.sql rename to src/Sql/Platform/dbo/Views/InstallationView.sql diff --git a/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs index 9dcfee78af..0103650777 100644 --- a/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Services; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs index cd7f85ae8b..4e42125dce 100644 --- a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs +++ b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs index 3388956156..ba40198ef6 100644 --- a/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs +++ b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs @@ -8,7 +8,7 @@ using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; -using Bit.Core.Services; +using Bit.Core.Platform.Push; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs index b650d17240..53263d8805 100644 --- a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs +++ b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey.Implementations; +using Bit.Core.Platform.Push; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs index ea9ce54131..c26fc23460 100644 --- a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs +++ b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs @@ -1,6 +1,6 @@ using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NSubstitute; diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs index 52bee7068f..44c87f7182 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs @@ -1,12 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationLicenses; -using Bit.Core.Repositories; +using Bit.Core.Platform.Installations; using Bit.Core.Services; using Bit.Core.Test.AutoFixture; using Bit.Test.Common.AutoFixture; diff --git a/test/Core.Test/Services/AzureQueuePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs similarity index 90% rename from test/Core.Test/Services/AzureQueuePushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs index 7f9cb750aa..85ce5a79ac 100644 --- a/test/Core.Test/Services/AzureQueuePushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs @@ -1,10 +1,9 @@ -using Bit.Core.Services; -using Bit.Core.Settings; +using Bit.Core.Settings; using Microsoft.AspNetCore.Http; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Platform.Push.Internal.Test; public class AzureQueuePushNotificationServiceTests { diff --git a/test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs similarity index 96% rename from test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs index 68d6c50a7e..021aa7f2cc 100644 --- a/test/Core.Test/Services/MultiServicePushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/MultiServicePushNotificationServiceTests.cs @@ -1,12 +1,11 @@ using AutoFixture; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; using GlobalSettingsCustomization = Bit.Test.Common.AutoFixture.GlobalSettings; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Platform.Push.Internal.Test; public class MultiServicePushNotificationServiceTests { diff --git a/test/Core.Test/Services/NotificationsApiPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs similarity index 93% rename from test/Core.Test/Services/NotificationsApiPushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs index d1ba15d6a5..78f60da359 100644 --- a/test/Core.Test/Services/NotificationsApiPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs @@ -1,11 +1,10 @@ -using Bit.Core.Services; -using Bit.Core.Settings; +using Bit.Core.Settings; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Platform.Push.Internal.Test; public class NotificationsApiPushNotificationServiceTests { diff --git a/test/Core.Test/Services/RelayPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs similarity index 95% rename from test/Core.Test/Services/RelayPushNotificationServiceTests.cs rename to test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs index ccf5e3d4bb..61d7f0a788 100644 --- a/test/Core.Test/Services/RelayPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs @@ -1,12 +1,11 @@ using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Platform.Push.Internal.Test; public class RelayPushNotificationServiceTests { diff --git a/test/Core.Test/Services/RelayPushRegistrationServiceTests.cs b/test/Core.Test/Platform/Push/Services/RelayPushRegistrationServiceTests.cs similarity index 91% rename from test/Core.Test/Services/RelayPushRegistrationServiceTests.cs rename to test/Core.Test/Platform/Push/Services/RelayPushRegistrationServiceTests.cs index 926a19bc00..cfd843d2eb 100644 --- a/test/Core.Test/Services/RelayPushRegistrationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/RelayPushRegistrationServiceTests.cs @@ -1,10 +1,9 @@ -using Bit.Core.Services; -using Bit.Core.Settings; +using Bit.Core.Settings; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Platform.Push.Internal.Test; public class RelayPushRegistrationServiceTests { diff --git a/test/Core.Test/Services/DeviceServiceTests.cs b/test/Core.Test/Services/DeviceServiceTests.cs index cb2aebc992..41ef0b4d74 100644 --- a/test/Core.Test/Services/DeviceServiceTests.cs +++ b/test/Core.Test/Services/DeviceServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index e44609c6d6..74bebf328f 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -18,6 +18,7 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/test/Core.Test/Tools/Services/SendServiceTests.cs b/test/Core.Test/Tools/Services/SendServiceTests.cs index 0174efa67e..7ef6f915dd 100644 --- a/test/Core.Test/Tools/Services/SendServiceTests.cs +++ b/test/Core.Test/Tools/Services/SendServiceTests.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CurrentContextFixtures; diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 0df8f67490..dd34127efe 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CipherFixtures; diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index ae64b832fe..38a1518d14 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Identity.IdentityServer; using Bit.Identity.Models.Request.Accounts; @@ -462,7 +463,7 @@ public class IdentityServerTests : IClassFixture } [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeClientCredentials_AsInstallation_InstallationExists_Succeeds(Bit.Core.Entities.Installation installation) + public async Task TokenEndpoint_GrantTypeClientCredentials_AsInstallation_InstallationExists_Succeeds(Installation installation) { var installationRepo = _factory.Services.GetRequiredService(); installation = await installationRepo.CreateAsync(installation); diff --git a/test/Infrastructure.EFIntegration.Test/AutoFixture/EntityFrameworkRepositoryFixtures.cs b/test/Infrastructure.EFIntegration.Test/AutoFixture/EntityFrameworkRepositoryFixtures.cs index 3775c9953d..0ebcf8903d 100644 --- a/test/Infrastructure.EFIntegration.Test/AutoFixture/EntityFrameworkRepositoryFixtures.cs +++ b/test/Infrastructure.EFIntegration.Test/AutoFixture/EntityFrameworkRepositoryFixtures.cs @@ -8,6 +8,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using Bit.Infrastructure.EntityFramework.Auth.Models; using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Platform; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Tools.Models; using Bit.Infrastructure.EntityFramework.Vault.Models; diff --git a/test/Infrastructure.EFIntegration.Test/AutoFixture/InstallationFixtures.cs b/test/Infrastructure.EFIntegration.Test/AutoFixture/InstallationFixtures.cs index c090a2e38e..7b57824442 100644 --- a/test/Infrastructure.EFIntegration.Test/AutoFixture/InstallationFixtures.cs +++ b/test/Infrastructure.EFIntegration.Test/AutoFixture/InstallationFixtures.cs @@ -1,9 +1,9 @@ using AutoFixture; using AutoFixture.Kernel; -using Bit.Core.Entities; -using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using C = Bit.Core.Platform.Installations; +using Ef = Bit.Infrastructure.EntityFramework.Platform; namespace Bit.Infrastructure.EFIntegration.Test.AutoFixture; @@ -17,13 +17,13 @@ internal class InstallationBuilder : ISpecimenBuilder } var type = request as Type; - if (type == null || type != typeof(Installation)) + if (type == null || type != typeof(C.Installation)) { return new NoSpecimen(); } var fixture = new Fixture(); - var obj = fixture.WithAutoNSubstitutions().Create(); + var obj = fixture.WithAutoNSubstitutions().Create(); return obj; } } @@ -35,7 +35,7 @@ internal class EfInstallation : ICustomization fixture.Customizations.Add(new IgnoreVirtualMembersCustomization()); fixture.Customizations.Add(new GlobalSettingsBuilder()); fixture.Customizations.Add(new InstallationBuilder()); - fixture.Customizations.Add(new EfRepositoryListBuilder()); + fixture.Customizations.Add(new EfRepositoryListBuilder()); } } diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/EqualityComparers/InstallationCompare.cs b/test/Infrastructure.EFIntegration.Test/Platform/Installations/Repositories/InstallationCompare.cs similarity index 78% rename from test/Infrastructure.EFIntegration.Test/Repositories/EqualityComparers/InstallationCompare.cs rename to test/Infrastructure.EFIntegration.Test/Platform/Installations/Repositories/InstallationCompare.cs index 7794785b31..9b685f8095 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/EqualityComparers/InstallationCompare.cs +++ b/test/Infrastructure.EFIntegration.Test/Platform/Installations/Repositories/InstallationCompare.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; -using Bit.Core.Entities; +using Bit.Core.Platform.Installations; -namespace Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers; +namespace Bit.Infrastructure.EFIntegration.Test.Platform; public class InstallationCompare : IEqualityComparer { diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/InstallationRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Platform/Installations/Repositories/InstallationRepositoryTests.cs similarity index 64% rename from test/Infrastructure.EFIntegration.Test/Repositories/InstallationRepositoryTests.cs rename to test/Infrastructure.EFIntegration.Test/Platform/Installations/Repositories/InstallationRepositoryTests.cs index 3e4f7eb5df..e57b2311ef 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/InstallationRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Platform/Installations/Repositories/InstallationRepositoryTests.cs @@ -1,24 +1,23 @@ -using Bit.Core.Entities; -using Bit.Core.Test.AutoFixture.Attributes; +using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; -using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers; using Xunit; -using EfRepo = Bit.Infrastructure.EntityFramework.Repositories; -using SqlRepo = Bit.Infrastructure.Dapper.Repositories; +using C = Bit.Core.Platform.Installations; +using D = Bit.Infrastructure.Dapper.Platform; +using Ef = Bit.Infrastructure.EntityFramework.Platform; -namespace Bit.Infrastructure.EFIntegration.Test.Repositories; +namespace Bit.Infrastructure.EFIntegration.Test.Platform; public class InstallationRepositoryTests { [CiSkippedTheory, EfInstallationAutoData] public async Task CreateAsync_Works_DataMatches( - Installation installation, + C.Installation installation, InstallationCompare equalityComparer, - List suts, - SqlRepo.InstallationRepository sqlInstallationRepo + List suts, + D.InstallationRepository sqlInstallationRepo ) { - var savedInstallations = new List(); + var savedInstallations = new List(); foreach (var sut in suts) { var postEfInstallation = await sut.CreateAsync(installation); diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 3ce2599705..9474ffb862 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -1,5 +1,7 @@ using AspNetCoreRateLimit; using Bit.Core.Auth.Services; +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Services; From 90f7bfe63d39abb32b04206b72e4ebeab8dae458 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 6 Jan 2025 16:22:03 -0500 Subject: [PATCH 16/16] chore: update `LastActivityDate` on installation token refresh (#5081) --- .../Controllers/InstallationsController.cs | 9 ++++ src/Core/Constants.cs | 1 + .../IUpdateInstallationCommand.cs | 14 +++++ .../UpdateInstallationCommand.cs | 53 +++++++++++++++++++ .../Installations/Entities/Installation.cs | 1 + .../GetInstallationQuery.cs | 30 +++++++++++ .../IGetInstallationQuery.cs | 20 +++++++ .../PlatformServiceCollectionExtensions.cs | 19 +++++++ .../Services/RelayPushRegistrationService.cs | 1 - .../CustomTokenRequestValidator.cs | 43 +++++++++++++-- .../WebAuthnGrantValidator.cs | 3 +- .../Utilities/ServiceCollectionExtensions.cs | 2 + .../UpdateInstallationCommandTests.cs | 40 ++++++++++++++ 13 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs create mode 100644 src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs create mode 100644 src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs create mode 100644 src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs create mode 100644 src/Core/Platform/PlatformServiceCollectionExtensions.cs create mode 100644 test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs diff --git a/src/Api/Platform/Installations/Controllers/InstallationsController.cs b/src/Api/Platform/Installations/Controllers/InstallationsController.cs index a9ba4e6c02..96cdc9d95c 100644 --- a/src/Api/Platform/Installations/Controllers/InstallationsController.cs +++ b/src/Api/Platform/Installations/Controllers/InstallationsController.cs @@ -6,6 +6,15 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Platform.Installations; +/// +/// Routes used to manipulate `Installation` objects: a type used to manage +/// a record of a self hosted installation. +/// +/// +/// This controller is not called from any clients. It's primarily referenced +/// in the `Setup` project for creating a new self hosted installation. +/// +/// Bit.Setup.Program [Route("installations")] [SelfHosted(NotSelfHostedOnly = true)] public class InstallationsController : Controller diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0b7435cf8f..830e3f65b2 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -165,6 +165,7 @@ public static class FeatureFlagKeys public const string AppReviewPrompt = "app-review-prompt"; public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; public const string UsePricingService = "use-pricing-service"; + public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; public static List GetAllKeys() { diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs new file mode 100644 index 0000000000..d0c25b96a4 --- /dev/null +++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Command interface responsible for updating data on an `Installation` +/// record. +/// +/// +/// This interface is implemented by `UpdateInstallationCommand` +/// +/// +public interface IUpdateInstallationCommand +{ + Task UpdateLastActivityDateAsync(Guid installationId); +} diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs new file mode 100644 index 0000000000..4b0bc3bbe8 --- /dev/null +++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs @@ -0,0 +1,53 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Commands responsible for updating an installation from +/// `InstallationRepository`. +/// +/// +/// If referencing: you probably want the interface +/// `IUpdateInstallationCommand` instead of directly calling this class. +/// +/// +public class UpdateInstallationCommand : IUpdateInstallationCommand +{ + private readonly IGetInstallationQuery _getInstallationQuery; + private readonly IInstallationRepository _installationRepository; + private readonly TimeProvider _timeProvider; + + public UpdateInstallationCommand( + IGetInstallationQuery getInstallationQuery, + IInstallationRepository installationRepository, + TimeProvider timeProvider + ) + { + _getInstallationQuery = getInstallationQuery; + _installationRepository = installationRepository; + _timeProvider = timeProvider; + } + + public async Task UpdateLastActivityDateAsync(Guid installationId) + { + if (installationId == default) + { + throw new Exception + ( + "Tried to update the last activity date for " + + "an installation, but an invalid installation id was " + + "provided." + ); + } + var installation = await _getInstallationQuery.GetByIdAsync(installationId); + if (installation == null) + { + throw new Exception + ( + "Tried to update the last activity date for " + + $"installation {installationId.ToString()}, but no " + + "installation was found for that id." + ); + } + installation.LastActivityDate = _timeProvider.GetUtcNow().UtcDateTime; + await _installationRepository.UpsertAsync(installation); + } +} diff --git a/src/Core/Platform/Installations/Entities/Installation.cs b/src/Core/Platform/Installations/Entities/Installation.cs index 63aa5d1e24..acd53db0fb 100644 --- a/src/Core/Platform/Installations/Entities/Installation.cs +++ b/src/Core/Platform/Installations/Entities/Installation.cs @@ -19,6 +19,7 @@ public class Installation : ITableObject public string Key { get; set; } = null!; public bool Enabled { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime? LastActivityDate { get; internal set; } public void SetNewId() { diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs new file mode 100644 index 0000000000..b0d8745800 --- /dev/null +++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs @@ -0,0 +1,30 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Queries responsible for fetching an installation from +/// `InstallationRepository`. +/// +/// +/// If referencing: you probably want the interface `IGetInstallationQuery` +/// instead of directly calling this class. +/// +/// +public class GetInstallationQuery : IGetInstallationQuery +{ + private readonly IInstallationRepository _installationRepository; + + public GetInstallationQuery(IInstallationRepository installationRepository) + { + _installationRepository = installationRepository; + } + + /// + public async Task GetByIdAsync(Guid installationId) + { + if (installationId == default(Guid)) + { + return null; + } + return await _installationRepository.GetByIdAsync(installationId); + } +} diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs new file mode 100644 index 0000000000..9615cf986d --- /dev/null +++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs @@ -0,0 +1,20 @@ +namespace Bit.Core.Platform.Installations; + +/// +/// Query interface responsible for fetching an installation from +/// `InstallationRepository`. +/// +/// +/// This interface is implemented by `GetInstallationQuery` +/// +/// +public interface IGetInstallationQuery +{ + /// + /// Retrieves an installation from the `InstallationRepository` by its id. + /// + /// The GUID id of the installation. + /// A task containing an `Installation`. + /// + Task GetByIdAsync(Guid installationId); +} diff --git a/src/Core/Platform/PlatformServiceCollectionExtensions.cs b/src/Core/Platform/PlatformServiceCollectionExtensions.cs new file mode 100644 index 0000000000..bba0b0aedd --- /dev/null +++ b/src/Core/Platform/PlatformServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Bit.Core.Platform.Installations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Platform; + +public static class PlatformServiceCollectionExtensions +{ + /// + /// Extend DI to include commands and queries exported from the Platform + /// domain. + /// + public static IServiceCollection AddPlatformServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs index a42a831266..79b033e877 100644 --- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs @@ -9,7 +9,6 @@ namespace Bit.Core.Platform.Push.Internal; public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService { - public RelayPushRegistrationService( IHttpClientFactory httpFactory, GlobalSettings globalSettings, diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index fb7b129b09..597d5257e2 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -1,11 +1,13 @@ using System.Diagnostics; using System.Security.Claims; +using Bit.Core; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.IdentityServer; +using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -23,6 +25,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator _userManager; + private readonly IUpdateInstallationCommand _updateInstallationCommand; public CustomTokenRequestValidator( UserManager userManager, @@ -39,7 +42,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator { { "encrypted_payload", payload } }; + + } + if (FeatureService.IsEnabled(FeatureFlagKeys.RecordInstallationLastActivityDate) + && context.Result.ValidatedRequest.ClientId.StartsWith("installation")) + { + var installationIdPart = clientId.Split(".")[1]; + await RecordActivityForInstallation(clientId.Split(".")[1]); } return; } @@ -152,6 +165,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator + /// To help mentally separate organizations that self host from abandoned + /// organizations we hook in to the token refresh event for installations + /// to write a simple `DateTime.Now` to the database. + /// + /// + /// This works well because installations don't phone home very often. + /// Currently self hosted installations only refresh tokens every 24 + /// hours or so for the sake of hooking in to cloud's push relay service. + /// If installations ever start refreshing tokens more frequently we may need to + /// adjust this to avoid making a bunch of unnecessary database calls! + /// + private async Task RecordActivityForInstallation(string? installationIdString) + { + if (!Guid.TryParse(installationIdString, out var installationId)) + { + return; + } + await _updateInstallationCommand.UpdateLastActivityDateAsync(installationId); + } } diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 499c22ad89..085ed15efd 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -44,8 +44,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator assertionOptionsDataProtector, IFeatureService featureService, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, - IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand - ) + IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand) : base( userManager, userService, diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 46f8293d3e..891b8d6664 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -30,6 +30,7 @@ using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; +using Bit.Core.Platform; using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; @@ -126,6 +127,7 @@ public static class ServiceCollectionExtensions services.AddReportingServices(); services.AddKeyManagementServices(); services.AddNotificationCenterServices(); + services.AddPlatformServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs b/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs new file mode 100644 index 0000000000..daa8e1b89c --- /dev/null +++ b/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs @@ -0,0 +1,40 @@ +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Platform.Installations.Tests; + +[SutProviderCustomize] +public class UpdateInstallationCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateLastActivityDateAsync_ShouldUpdateLastActivityDate( + Installation installation + ) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + var someDate = new DateTime(2014, 11, 3, 18, 27, 0, DateTimeKind.Utc); + sutProvider.GetDependency().SetUtcNow(someDate); + + sutProvider + .GetDependency() + .GetByIdAsync(installation.Id) + .Returns(installation); + + // Act + await sutProvider.Sut.UpdateLastActivityDateAsync(installation.Id); + + // Assert + await sutProvider + .GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(inst => inst.LastActivityDate == someDate)); + } +}