mirror of
https://github.com/bitwarden/server.git
synced 2025-04-10 23:58:13 -05:00
Merge branch 'main' into PM-16517-Additional-storage-separate-product-personal-use
This commit is contained in:
commit
809b85af78
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -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
|
||||
|
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@ -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
|
||||
@ -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()
|
||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -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 }}
|
||||
|
@ -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<string, string>
|
||||
{
|
||||
{ "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);
|
||||
|
@ -746,6 +746,12 @@ public class ProviderBillingServiceTests
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
@ -777,6 +783,29 @@ public class ProviderBillingServiceTests
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns((string)null);
|
||||
|
||||
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetupCustomer(provider, taxInfo));
|
||||
|
||||
Assert.IsType<BadRequestException>(actual);
|
||||
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SetupSubscription
|
||||
|
@ -0,0 +1,151 @@
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.Billing;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class TaxServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData("AD", "A-123456-Z", "ad_nrt")]
|
||||
[BitAutoData("AD", "A123456Z", "ad_nrt")]
|
||||
[BitAutoData("AR", "20-12345678-9", "ar_cuit")]
|
||||
[BitAutoData("AR", "20123456789", "ar_cuit")]
|
||||
[BitAutoData("AU", "01259983598", "au_abn")]
|
||||
[BitAutoData("AU", "123456789123", "au_arn")]
|
||||
[BitAutoData("AT", "ATU12345678", "eu_vat")]
|
||||
[BitAutoData("BH", "123456789012345", "bh_vat")]
|
||||
[BitAutoData("BY", "123456789", "by_tin")]
|
||||
[BitAutoData("BE", "BE0123456789", "eu_vat")]
|
||||
[BitAutoData("BO", "123456789", "bo_tin")]
|
||||
[BitAutoData("BR", "01.234.456/5432-10", "br_cnpj")]
|
||||
[BitAutoData("BR", "01234456543210", "br_cnpj")]
|
||||
[BitAutoData("BR", "123.456.789-87", "br_cpf")]
|
||||
[BitAutoData("BR", "12345678987", "br_cpf")]
|
||||
[BitAutoData("BG", "123456789", "bg_uic")]
|
||||
[BitAutoData("BG", "BG012100705", "eu_vat")]
|
||||
[BitAutoData("CA", "100728494", "ca_bn")]
|
||||
[BitAutoData("CA", "123456789RT0001", "ca_gst_hst")]
|
||||
[BitAutoData("CA", "PST-1234-1234", "ca_pst_bc")]
|
||||
[BitAutoData("CA", "123456-7", "ca_pst_mb")]
|
||||
[BitAutoData("CA", "1234567", "ca_pst_sk")]
|
||||
[BitAutoData("CA", "1234567890TQ1234", "ca_qst")]
|
||||
[BitAutoData("CL", "11.121.326-1", "cl_tin")]
|
||||
[BitAutoData("CL", "11121326-1", "cl_tin")]
|
||||
[BitAutoData("CL", "23.121.326-K", "cl_tin")]
|
||||
[BitAutoData("CL", "43651326-K", "cl_tin")]
|
||||
[BitAutoData("CN", "123456789012345678", "cn_tin")]
|
||||
[BitAutoData("CN", "123456789012345", "cn_tin")]
|
||||
[BitAutoData("CO", "123.456.789-0", "co_nit")]
|
||||
[BitAutoData("CO", "1234567890", "co_nit")]
|
||||
[BitAutoData("CR", "1-234-567890", "cr_tin")]
|
||||
[BitAutoData("CR", "1234567890", "cr_tin")]
|
||||
[BitAutoData("HR", "HR12345678912", "eu_vat")]
|
||||
[BitAutoData("HR", "12345678901", "hr_oib")]
|
||||
[BitAutoData("CY", "CY12345678X", "eu_vat")]
|
||||
[BitAutoData("CZ", "CZ12345678", "eu_vat")]
|
||||
[BitAutoData("DK", "DK12345678", "eu_vat")]
|
||||
[BitAutoData("DO", "123-4567890-1", "do_rcn")]
|
||||
[BitAutoData("DO", "12345678901", "do_rcn")]
|
||||
[BitAutoData("EC", "1234567890001", "ec_ruc")]
|
||||
[BitAutoData("EG", "123456789", "eg_tin")]
|
||||
[BitAutoData("SV", "1234-567890-123-4", "sv_nit")]
|
||||
[BitAutoData("SV", "12345678901234", "sv_nit")]
|
||||
[BitAutoData("EE", "EE123456789", "eu_vat")]
|
||||
[BitAutoData("EU", "EU123456789", "eu_oss_vat")]
|
||||
[BitAutoData("FI", "FI12345678", "eu_vat")]
|
||||
[BitAutoData("FR", "FR12345678901", "eu_vat")]
|
||||
[BitAutoData("GE", "123456789", "ge_vat")]
|
||||
[BitAutoData("DE", "1234567890", "de_stn")]
|
||||
[BitAutoData("DE", "DE123456789", "eu_vat")]
|
||||
[BitAutoData("GR", "EL123456789", "eu_vat")]
|
||||
[BitAutoData("HK", "12345678", "hk_br")]
|
||||
[BitAutoData("HU", "HU12345678", "eu_vat")]
|
||||
[BitAutoData("HU", "12345678-1-23", "hu_tin")]
|
||||
[BitAutoData("HU", "12345678123", "hu_tin")]
|
||||
[BitAutoData("IS", "123456", "is_vat")]
|
||||
[BitAutoData("IN", "12ABCDE1234F1Z5", "in_gst")]
|
||||
[BitAutoData("IN", "12ABCDE3456FGZH", "in_gst")]
|
||||
[BitAutoData("ID", "012.345.678.9-012.345", "id_npwp")]
|
||||
[BitAutoData("ID", "0123456789012345", "id_npwp")]
|
||||
[BitAutoData("IE", "IE1234567A", "eu_vat")]
|
||||
[BitAutoData("IE", "IE1234567AB", "eu_vat")]
|
||||
[BitAutoData("IL", "000012345", "il_vat")]
|
||||
[BitAutoData("IL", "123456789", "il_vat")]
|
||||
[BitAutoData("IT", "IT12345678901", "eu_vat")]
|
||||
[BitAutoData("JP", "1234567890123", "jp_cn")]
|
||||
[BitAutoData("JP", "12345", "jp_rn")]
|
||||
[BitAutoData("KZ", "123456789012", "kz_bin")]
|
||||
[BitAutoData("KE", "P000111111A", "ke_pin")]
|
||||
[BitAutoData("LV", "LV12345678912", "eu_vat")]
|
||||
[BitAutoData("LI", "CHE123456789", "li_uid")]
|
||||
[BitAutoData("LI", "12345", "li_vat")]
|
||||
[BitAutoData("LT", "LT123456789123", "eu_vat")]
|
||||
[BitAutoData("LU", "LU12345678", "eu_vat")]
|
||||
[BitAutoData("MY", "12345678", "my_frp")]
|
||||
[BitAutoData("MY", "C 1234567890", "my_itn")]
|
||||
[BitAutoData("MY", "C1234567890", "my_itn")]
|
||||
[BitAutoData("MY", "A12-3456-78912345", "my_sst")]
|
||||
[BitAutoData("MY", "A12345678912345", "my_sst")]
|
||||
[BitAutoData("MT", "MT12345678", "eu_vat")]
|
||||
[BitAutoData("MX", "ABC010203AB9", "mx_rfc")]
|
||||
[BitAutoData("MD", "1003600", "md_vat")]
|
||||
[BitAutoData("MA", "12345678", "ma_vat")]
|
||||
[BitAutoData("NL", "NL123456789B12", "eu_vat")]
|
||||
[BitAutoData("NZ", "123456789", "nz_gst")]
|
||||
[BitAutoData("NG", "12345678-0001", "ng_tin")]
|
||||
[BitAutoData("NO", "123456789MVA", "no_vat")]
|
||||
[BitAutoData("NO", "1234567", "no_voec")]
|
||||
[BitAutoData("OM", "OM1234567890", "om_vat")]
|
||||
[BitAutoData("PE", "12345678901", "pe_ruc")]
|
||||
[BitAutoData("PH", "123456789012", "ph_tin")]
|
||||
[BitAutoData("PL", "PL1234567890", "eu_vat")]
|
||||
[BitAutoData("PT", "PT123456789", "eu_vat")]
|
||||
[BitAutoData("RO", "RO1234567891", "eu_vat")]
|
||||
[BitAutoData("RO", "1234567890123", "ro_tin")]
|
||||
[BitAutoData("RU", "1234567891", "ru_inn")]
|
||||
[BitAutoData("RU", "123456789", "ru_kpp")]
|
||||
[BitAutoData("SA", "123456789012345", "sa_vat")]
|
||||
[BitAutoData("RS", "123456789", "rs_pib")]
|
||||
[BitAutoData("SG", "M12345678X", "sg_gst")]
|
||||
[BitAutoData("SG", "123456789F", "sg_uen")]
|
||||
[BitAutoData("SK", "SK1234567891", "eu_vat")]
|
||||
[BitAutoData("SI", "SI12345678", "eu_vat")]
|
||||
[BitAutoData("SI", "12345678", "si_tin")]
|
||||
[BitAutoData("ZA", "4123456789", "za_vat")]
|
||||
[BitAutoData("KR", "123-45-67890", "kr_brn")]
|
||||
[BitAutoData("KR", "1234567890", "kr_brn")]
|
||||
[BitAutoData("ES", "A12345678", "es_cif")]
|
||||
[BitAutoData("ES", "ESX1234567X", "eu_vat")]
|
||||
[BitAutoData("SE", "SE123456789012", "eu_vat")]
|
||||
[BitAutoData("CH", "CHE-123.456.789 HR", "ch_uid")]
|
||||
[BitAutoData("CH", "CHE123456789HR", "ch_uid")]
|
||||
[BitAutoData("CH", "CHE-123.456.789 MWST", "ch_vat")]
|
||||
[BitAutoData("CH", "CHE123456789MWST", "ch_vat")]
|
||||
[BitAutoData("TW", "12345678", "tw_vat")]
|
||||
[BitAutoData("TH", "1234567890123", "th_vat")]
|
||||
[BitAutoData("TR", "0123456789", "tr_tin")]
|
||||
[BitAutoData("UA", "123456789", "ua_vat")]
|
||||
[BitAutoData("AE", "123456789012345", "ae_trn")]
|
||||
[BitAutoData("GB", "XI123456789", "eu_vat")]
|
||||
[BitAutoData("GB", "GB123456789", "gb_vat")]
|
||||
[BitAutoData("US", "12-3456789", "us_ein")]
|
||||
[BitAutoData("UY", "123456789012", "uy_ruc")]
|
||||
[BitAutoData("UZ", "123456789", "uz_tin")]
|
||||
[BitAutoData("UZ", "123456789012", "uz_vat")]
|
||||
[BitAutoData("VE", "A-12345678-9", "ve_rif")]
|
||||
[BitAutoData("VE", "A123456789", "ve_rif")]
|
||||
[BitAutoData("VN", "1234567890", "vn_tin")]
|
||||
public void GetStripeTaxCode_WithValidCountryAndTaxId_ReturnsExpectedTaxIdType(
|
||||
string country,
|
||||
string taxId,
|
||||
string expected,
|
||||
SutProvider<TaxService> sutProvider)
|
||||
{
|
||||
var result = sutProvider.Sut.GetStripeTaxCode(country, taxId);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -92,7 +92,7 @@
|
||||
@if (canPromoteAdmin)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="Tools" asp-action="PromoteAdmin">
|
||||
Promote Admin
|
||||
Promote Organization Admin
|
||||
</a>
|
||||
}
|
||||
@if (canPromoteProviderServiceUser)
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
@ -77,4 +78,18 @@ public class AccountsBillingController(
|
||||
|
||||
return TypedResults.Ok(transactions);
|
||||
}
|
||||
|
||||
[HttpPost("preview-invoice")]
|
||||
public async Task<IResult> PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId);
|
||||
|
||||
return TypedResults.Ok(invoice);
|
||||
}
|
||||
}
|
||||
|
42
src/Api/Billing/Controllers/InvoicesController.cs
Normal file
42
src/Api/Billing/Controllers/InvoicesController.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("invoices")]
|
||||
[Authorize("Application")]
|
||||
public class InvoicesController : BaseBillingController
|
||||
{
|
||||
[HttpPost("preview-organization")]
|
||||
public async Task<IResult> PreviewInvoiceAsync(
|
||||
[FromBody] PreviewOrganizationInvoiceRequestBody model,
|
||||
[FromServices] ICurrentContext currentContext,
|
||||
[FromServices] IOrganizationRepository organizationRepository,
|
||||
[FromServices] IPaymentService paymentService)
|
||||
{
|
||||
Organization organization = null;
|
||||
if (model.OrganizationId != default)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(model.OrganizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
organization = await organizationRepository.GetByIdAsync(model.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId,
|
||||
organization?.GatewaySubscriptionId);
|
||||
|
||||
return TypedResults.Ok(invoice);
|
||||
}
|
||||
}
|
@ -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<ProfileOrganizationResponseModel> 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<ProfileOrganizationResponseModel> 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<ProfileOrganizationResponseModel> 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);
|
||||
}
|
||||
}
|
||||
|
@ -119,6 +119,7 @@ public class ProviderBillingController(
|
||||
requestBody.Country,
|
||||
requestBody.PostalCode,
|
||||
requestBody.TaxId,
|
||||
requestBody.TaxIdType,
|
||||
requestBody.Line1,
|
||||
requestBody.Line2,
|
||||
requestBody.City,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,13 +1,20 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Routes used to manipulate `Installation` objects: a type used to manage
|
||||
/// a record of a self hosted installation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This controller is not called from any clients. It's primarily referenced
|
||||
/// in the `Setup` project for creating a new self hosted installation.
|
||||
/// </remarks>
|
||||
/// <seealso>Bit.Setup.Program</seealso>
|
||||
[Route("installations")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class InstallationsController : Controller
|
@ -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
|
||||
{
|
@ -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
|
||||
{
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Routes for push relay: functionality that facilitates communication
|
||||
/// between self hosted organizations and Bitwarden cloud.
|
||||
/// </summary>
|
||||
[Route("push")]
|
||||
[Authorize("Push")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
33
src/Core/Billing/Extensions/CurrencyExtensions.cs
Normal file
33
src/Core/Billing/Extensions/CurrencyExtensions.cs
Normal file
@ -0,0 +1,33 @@
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class CurrencyExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a currency amount in major units to minor units.
|
||||
/// </summary>
|
||||
/// <example>123.99 USD returns 12399 in minor units.</example>
|
||||
public static long ToMinor(this decimal amount)
|
||||
{
|
||||
return Convert.ToInt64(amount * 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a currency amount in minor units to major units.
|
||||
/// </summary>
|
||||
/// <param name="amount"></param>
|
||||
/// <example>12399 in minor units returns 123.99 USD.</example>
|
||||
public static decimal? ToMajor(this long? amount)
|
||||
{
|
||||
return amount?.ToMajor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a currency amount in minor units to major units.
|
||||
/// </summary>
|
||||
/// <param name="amount"></param>
|
||||
/// <example>12399 in minor units returns 123.99 USD.</example>
|
||||
public static decimal ToMajor(this long amount)
|
||||
{
|
||||
return Convert.ToDecimal(amount) / 100;
|
||||
}
|
||||
}
|
@ -12,10 +12,12 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static void AddBillingOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ITaxService, TaxService>();
|
||||
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
||||
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||
// services.AddSingleton<IPricingClient, PricingClient>();
|
||||
services.AddLicenseServices();
|
||||
}
|
||||
}
|
||||
|
@ -12,26 +12,42 @@ public class UserLicenseClaimsFactory : ILicenseClaimsFactory<User>
|
||||
{
|
||||
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<Claim>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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; }
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Billing.Models.Api.Responses;
|
||||
|
||||
public record PreviewInvoiceResponseModel(
|
||||
decimal EffectiveTaxRate,
|
||||
decimal TaxableBaseAmount,
|
||||
decimal TaxAmount,
|
||||
decimal TotalAmount);
|
7
src/Core/Billing/Models/PreviewInvoiceInfo.cs
Normal file
7
src/Core/Billing/Models/PreviewInvoiceInfo.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record PreviewInvoiceInfo(
|
||||
decimal EffectiveTaxRate,
|
||||
decimal TaxableBaseAmount,
|
||||
decimal TaxAmount,
|
||||
decimal TotalAmount);
|
@ -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,
|
||||
|
@ -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; }
|
||||
|
22
src/Core/Billing/Models/TaxIdType.cs
Normal file
22
src/Core/Billing/Models/TaxIdType.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public class TaxIdType
|
||||
{
|
||||
/// <summary>
|
||||
/// ISO-3166-2 code for the country.
|
||||
/// </summary>
|
||||
public string Country { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The identifier in Stripe for the tax ID type.
|
||||
/// </summary>
|
||||
public string Code { get; set; }
|
||||
|
||||
public Regex ValidationExpression { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public string Example { get; set; }
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core.Models.Business;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
@ -7,6 +6,7 @@ public record TaxInformation(
|
||||
string Country,
|
||||
string PostalCode,
|
||||
string TaxId,
|
||||
string TaxIdType,
|
||||
string Line1,
|
||||
string Line2,
|
||||
string City,
|
||||
@ -16,165 +16,9 @@ public record TaxInformation(
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.BillingAddressPostalCode,
|
||||
taxInfo.TaxIdNumber,
|
||||
taxInfo.TaxIdType,
|
||||
taxInfo.BillingAddressLine1,
|
||||
taxInfo.BillingAddressLine2,
|
||||
taxInfo.BillingAddressCity,
|
||||
taxInfo.BillingAddressState);
|
||||
|
||||
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
|
||||
{
|
||||
var address = new AddressOptions
|
||||
{
|
||||
Country = Country,
|
||||
PostalCode = PostalCode,
|
||||
Line1 = Line1,
|
||||
Line2 = Line2,
|
||||
City = City,
|
||||
State = State
|
||||
};
|
||||
|
||||
var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId)
|
||||
? new List<CustomerTaxIdDataOptions> { new() { Type = GetTaxIdType(), Value = TaxId } }
|
||||
: null;
|
||||
|
||||
return (address, customerTaxIdDataOptionsList);
|
||||
}
|
||||
|
||||
public string GetTaxIdType()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (Country.ToUpper())
|
||||
{
|
||||
case "AD":
|
||||
return "ad_nrt";
|
||||
case "AE":
|
||||
return "ae_trn";
|
||||
case "AR":
|
||||
return "ar_cuit";
|
||||
case "AU":
|
||||
return "au_abn";
|
||||
case "BO":
|
||||
return "bo_tin";
|
||||
case "BR":
|
||||
return "br_cnpj";
|
||||
case "CA":
|
||||
// May break for those in Québec given the assumption of QST
|
||||
if (State?.Contains("bec") ?? false)
|
||||
{
|
||||
return "ca_qst";
|
||||
}
|
||||
return "ca_bn";
|
||||
case "CH":
|
||||
return "ch_vat";
|
||||
case "CL":
|
||||
return "cl_tin";
|
||||
case "CN":
|
||||
return "cn_tin";
|
||||
case "CO":
|
||||
return "co_nit";
|
||||
case "CR":
|
||||
return "cr_tin";
|
||||
case "DO":
|
||||
return "do_rcn";
|
||||
case "EC":
|
||||
return "ec_ruc";
|
||||
case "EG":
|
||||
return "eg_tin";
|
||||
case "GE":
|
||||
return "ge_vat";
|
||||
case "ID":
|
||||
return "id_npwp";
|
||||
case "IL":
|
||||
return "il_vat";
|
||||
case "IS":
|
||||
return "is_vat";
|
||||
case "KE":
|
||||
return "ke_pin";
|
||||
case "AT":
|
||||
case "BE":
|
||||
case "BG":
|
||||
case "CY":
|
||||
case "CZ":
|
||||
case "DE":
|
||||
case "DK":
|
||||
case "EE":
|
||||
case "ES":
|
||||
case "FI":
|
||||
case "FR":
|
||||
case "GB":
|
||||
case "GR":
|
||||
case "HR":
|
||||
case "HU":
|
||||
case "IE":
|
||||
case "IT":
|
||||
case "LT":
|
||||
case "LU":
|
||||
case "LV":
|
||||
case "MT":
|
||||
case "NL":
|
||||
case "PL":
|
||||
case "PT":
|
||||
case "RO":
|
||||
case "SE":
|
||||
case "SI":
|
||||
case "SK":
|
||||
return "eu_vat";
|
||||
case "HK":
|
||||
return "hk_br";
|
||||
case "IN":
|
||||
return "in_gst";
|
||||
case "JP":
|
||||
return "jp_cn";
|
||||
case "KR":
|
||||
return "kr_brn";
|
||||
case "LI":
|
||||
return "li_uid";
|
||||
case "MX":
|
||||
return "mx_rfc";
|
||||
case "MY":
|
||||
return "my_sst";
|
||||
case "NO":
|
||||
return "no_vat";
|
||||
case "NZ":
|
||||
return "nz_gst";
|
||||
case "PE":
|
||||
return "pe_ruc";
|
||||
case "PH":
|
||||
return "ph_tin";
|
||||
case "RS":
|
||||
return "rs_pib";
|
||||
case "RU":
|
||||
return "ru_inn";
|
||||
case "SA":
|
||||
return "sa_vat";
|
||||
case "SG":
|
||||
return "sg_gst";
|
||||
case "SV":
|
||||
return "sv_nit";
|
||||
case "TH":
|
||||
return "th_vat";
|
||||
case "TR":
|
||||
return "tr_tin";
|
||||
case "TW":
|
||||
return "tw_vat";
|
||||
case "UA":
|
||||
return "ua_vat";
|
||||
case "US":
|
||||
return "us_ein";
|
||||
case "UY":
|
||||
return "uy_ruc";
|
||||
case "VE":
|
||||
return "ve_rif";
|
||||
case "VN":
|
||||
return "vn_tin";
|
||||
case "ZA":
|
||||
return "za_vat";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
src/Core/Billing/Pricing/IPricingClient.cs
Normal file
12
src/Core/Billing/Pricing/IPricingClient.cs
Normal file
@ -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<Plan?> GetPlan(PlanType planType);
|
||||
Task<List<Plan>> ListPlans();
|
||||
}
|
232
src/Core/Billing/Pricing/PlanAdapter.cs
Normal file
232
src/Core/Billing/Pricing/PlanAdapter.cs
Normal file
@ -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
|
||||
}
|
92
src/Core/Billing/Pricing/PricingClient.cs
Normal file
92
src/Core/Billing/Pricing/PricingClient.cs
Normal file
@ -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<Plan?> 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<List<Plan>> 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
|
||||
};
|
||||
}
|
92
src/Core/Billing/Pricing/Protos/password-manager.proto
Normal file
92
src/Core/Billing/Pricing/Protos/password-manager.proto
Normal file
@ -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<string, string> 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;
|
||||
}
|
22
src/Core/Billing/Services/ITaxService.cs
Normal file
22
src/Core/Billing/Services/ITaxService.cs
Normal file
@ -0,0 +1,22 @@
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
public interface ITaxService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the Stripe tax code for a given country and tax ID.
|
||||
/// </summary>
|
||||
/// <param name="country"></param>
|
||||
/// <param name="taxId"></param>
|
||||
/// <returns>
|
||||
/// Returns the Stripe tax code if the tax ID is valid for the country.
|
||||
/// Returns null if the tax ID is invalid or the country is not supported.
|
||||
/// </returns>
|
||||
string GetStripeTaxCode(string country, string taxId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true or false whether charging or storing tax is supported for the given country.
|
||||
/// </summary>
|
||||
/// <param name="country"></param>
|
||||
/// <returns></returns>
|
||||
bool IsSupported(string country);
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -23,7 +23,8 @@ public class SubscriberService(
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<SubscriberService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter) : ISubscriberService
|
||||
IStripeAdapter stripeAdapter,
|
||||
ITaxService taxService) : ISubscriberService
|
||||
{
|
||||
public async Task CancelSubscription(
|
||||
ISubscriber subscriber,
|
||||
@ -609,25 +610,54 @@ public class SubscriberService(
|
||||
}
|
||||
});
|
||||
|
||||
if (!subscriber.IsUser())
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
|
||||
if (taxId != null)
|
||||
{
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||
}
|
||||
|
||||
if (taxId != null)
|
||||
if (string.IsNullOrWhiteSpace(taxInformation.TaxId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taxIdType = taxInformation.TaxIdType;
|
||||
if (string.IsNullOrWhiteSpace(taxIdType))
|
||||
{
|
||||
taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
|
||||
taxInformation.TaxId);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||
taxInformation.Country,
|
||||
taxInformation.TaxId);
|
||||
throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
}
|
||||
|
||||
var taxIdType = taxInformation.GetTaxIdType();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taxInformation.TaxId) &&
|
||||
!string.IsNullOrWhiteSpace(taxIdType))
|
||||
try
|
||||
{
|
||||
await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions
|
||||
{
|
||||
Type = taxIdType,
|
||||
Value = taxInformation.TaxId,
|
||||
});
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
taxInformation.TaxId,
|
||||
taxInformation.Country);
|
||||
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
|
||||
default:
|
||||
logger.LogError(e,
|
||||
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
|
||||
taxInformation.TaxId,
|
||||
taxInformation.Country,
|
||||
customer.Id);
|
||||
throw new Exceptions.BadRequestException("billingTaxIdCreationError");
|
||||
}
|
||||
}
|
||||
|
||||
@ -636,8 +666,7 @@ public class SubscriberService(
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||
DefaultTaxRates = []
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
|
||||
@ -770,6 +799,7 @@ public class SubscriberService(
|
||||
customer.Address.Country,
|
||||
customer.Address.PostalCode,
|
||||
customer.TaxIds?.FirstOrDefault()?.Value,
|
||||
customer.TaxIds?.FirstOrDefault()?.Type,
|
||||
customer.Address.Line1,
|
||||
customer.Address.Line2,
|
||||
customer.Address.City,
|
||||
|
901
src/Core/Billing/Services/TaxService.cs
Normal file
901
src/Core/Billing/Services/TaxService.cs
Normal file
@ -0,0 +1,901 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Bit.Core.Billing.Models;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
public class TaxService : ITaxService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a list of supported tax ID types for customers.
|
||||
/// </summary>
|
||||
/// <remarks>Compiled list from <see href="https://docs.stripe.com/billing/customer/tax-ids">Stripe</see></remarks>
|
||||
private static readonly IEnumerable<TaxIdType> _taxIdTypes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Country = "AD",
|
||||
Code = "ad_nrt",
|
||||
Description = "Andorran NRT number",
|
||||
Example = "A-123456-Z",
|
||||
ValidationExpression = new Regex("^([A-Z]{1})-?([0-9]{6})-?([A-Z]{1})$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "AR",
|
||||
Code = "ar_cuit",
|
||||
Description = "Argentinian tax ID number",
|
||||
Example = "12-34567890-1",
|
||||
ValidationExpression = new Regex("^([0-9]{2})-?([0-9]{8})-?([0-9]{1})$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "AU",
|
||||
Code = "au_abn",
|
||||
Description = "Australian Business Number (AU ABN)",
|
||||
Example = "123456789012",
|
||||
ValidationExpression = new Regex("^[0-9]{11}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "AU",
|
||||
Code = "au_arn",
|
||||
Description = "Australian Taxation Office Reference Number",
|
||||
Example = "123456789123",
|
||||
ValidationExpression = new Regex("^[0-9]{12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "AT",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Austria)",
|
||||
Example = "ATU12345678",
|
||||
ValidationExpression = new Regex("^ATU[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BH",
|
||||
Code = "bh_vat",
|
||||
Description = "Bahraini VAT Number",
|
||||
Example = "123456789012345",
|
||||
ValidationExpression = new Regex("^[0-9]{15}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BY",
|
||||
Code = "by_tin",
|
||||
Description = "Belarus TIN Number",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BE",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Belgium)",
|
||||
Example = "BE0123456789",
|
||||
ValidationExpression = new Regex("^BE[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BO",
|
||||
Code = "bo_tin",
|
||||
Description = "Bolivian tax ID",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BR",
|
||||
Code = "br_cnpj",
|
||||
Description = "Brazilian CNPJ number",
|
||||
Example = "01.234.456/5432-10",
|
||||
ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}/?[0-9]{4}-?[0-9]{2}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BR",
|
||||
Code = "br_cpf",
|
||||
Description = "Brazilian CPF number",
|
||||
Example = "123.456.789-87",
|
||||
ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{2}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BG",
|
||||
Code = "bg_uic",
|
||||
Description = "Bulgaria Unified Identification Code",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "BG",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Bulgaria)",
|
||||
Example = "BG0123456789",
|
||||
ValidationExpression = new Regex("^BG[0-9]{9,10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CA",
|
||||
Code = "ca_bn",
|
||||
Description = "Canadian BN",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CA",
|
||||
Code = "ca_gst_hst",
|
||||
Description = "Canadian GST/HST number",
|
||||
Example = "123456789RT0002",
|
||||
ValidationExpression = new Regex("^[0-9]{9}RT[0-9]{4}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CA",
|
||||
Code = "ca_pst_bc",
|
||||
Description = "Canadian PST number (British Columbia)",
|
||||
Example = "PST-1234-5678",
|
||||
ValidationExpression = new Regex("^PST-[0-9]{4}-[0-9]{4}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CA",
|
||||
Code = "ca_pst_mb",
|
||||
Description = "Canadian PST number (Manitoba)",
|
||||
Example = "123456-7",
|
||||
ValidationExpression = new Regex("^[0-9]{6}-[0-9]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CA",
|
||||
Code = "ca_pst_sk",
|
||||
Description = "Canadian PST number (Saskatchewan)",
|
||||
Example = "1234567",
|
||||
ValidationExpression = new Regex("^[0-9]{7}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CA",
|
||||
Code = "ca_qst",
|
||||
Description = "Canadian QST number (Québec)",
|
||||
Example = "1234567890TQ1234",
|
||||
ValidationExpression = new Regex("^[0-9]{10}TQ[0-9]{4}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CL",
|
||||
Code = "cl_tin",
|
||||
Description = "Chilean TIN",
|
||||
Example = "12.345.678-K",
|
||||
ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}-?[0-9A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CN",
|
||||
Code = "cn_tin",
|
||||
Description = "Chinese tax ID",
|
||||
Example = "123456789012345678",
|
||||
ValidationExpression = new Regex("^[0-9]{15,18}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CO",
|
||||
Code = "co_nit",
|
||||
Description = "Colombian NIT number",
|
||||
Example = "123.456.789-0",
|
||||
ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CR",
|
||||
Code = "cr_tin",
|
||||
Description = "Costa Rican tax ID",
|
||||
Example = "1-234-567890",
|
||||
ValidationExpression = new Regex("^[0-9]{1}-?[0-9]{3}-?[0-9]{6}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "HR",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Croatia)",
|
||||
Example = "HR12345678912",
|
||||
ValidationExpression = new Regex("^HR[0-9]{11}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "HR",
|
||||
Code = "hr_oib",
|
||||
Description = "Croatian Personal Identification Number",
|
||||
Example = "12345678901",
|
||||
ValidationExpression = new Regex("^[0-9]{11}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CY",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Cyprus)",
|
||||
Example = "CY12345678X",
|
||||
ValidationExpression = new Regex("^CY[0-9]{8}[A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CZ",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Czech Republic)",
|
||||
Example = "CZ12345678",
|
||||
ValidationExpression = new Regex("^CZ[0-9]{8,10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "DK",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Denmark)",
|
||||
Example = "DK12345678",
|
||||
ValidationExpression = new Regex("^DK[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "DO",
|
||||
Code = "do_rcn",
|
||||
Description = "Dominican RCN number",
|
||||
Example = "123-4567890-1",
|
||||
ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{7}-?[0-9]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "EC",
|
||||
Code = "ec_ruc",
|
||||
Description = "Ecuadorian RUC number",
|
||||
Example = "1234567890001",
|
||||
ValidationExpression = new Regex("^[0-9]{13}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "EG",
|
||||
Code = "eg_tin",
|
||||
Description = "Egyptian Tax Identification Number",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Country = "SV",
|
||||
Code = "sv_nit",
|
||||
Description = "El Salvadorian NIT number",
|
||||
Example = "1234-567890-123-4",
|
||||
ValidationExpression = new Regex("^[0-9]{4}-?[0-9]{6}-?[0-9]{3}-?[0-9]{1}$")
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Country = "EE",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Estonia)",
|
||||
Example = "EE123456789",
|
||||
ValidationExpression = new Regex("^EE[0-9]{9}$")
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Country = "EU",
|
||||
Code = "eu_oss_vat",
|
||||
Description = "European One Stop Shop VAT number for non-Union scheme",
|
||||
Example = "EU123456789",
|
||||
ValidationExpression = new Regex("^EU[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "FI",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Finland)",
|
||||
Example = "FI12345678",
|
||||
ValidationExpression = new Regex("^FI[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "FR",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (France)",
|
||||
Example = "FR12345678901",
|
||||
ValidationExpression = new Regex("^FR[0-9A-Z]{2}[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "GE",
|
||||
Code = "ge_vat",
|
||||
Description = "Georgian VAT",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "DE",
|
||||
Code = "de_stn",
|
||||
Description = "German Tax Number (Steuernummer)",
|
||||
Example = "1234567890",
|
||||
ValidationExpression = new Regex("^[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "DE",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Germany)",
|
||||
Example = "DE123456789",
|
||||
ValidationExpression = new Regex("^DE[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "GR",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Greece)",
|
||||
Example = "EL123456789",
|
||||
ValidationExpression = new Regex("^EL[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "HK",
|
||||
Code = "hk_br",
|
||||
Description = "Hong Kong BR number",
|
||||
Example = "12345678",
|
||||
ValidationExpression = new Regex("^[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "HU",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Hungaria)",
|
||||
Example = "HU12345678",
|
||||
ValidationExpression = new Regex("^HU[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "HU",
|
||||
Code = "hu_tin",
|
||||
Description = "Hungary tax number (adószám)",
|
||||
Example = "12345678-1-23",
|
||||
ValidationExpression = new Regex("^[0-9]{8}-?[0-9]-?[0-9]{2}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "IS",
|
||||
Code = "is_vat",
|
||||
Description = "Icelandic VAT",
|
||||
Example = "123456",
|
||||
ValidationExpression = new Regex("^[0-9]{6}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "IN",
|
||||
Code = "in_gst",
|
||||
Description = "Indian GST number",
|
||||
Example = "12ABCDE3456FGZH",
|
||||
ValidationExpression = new Regex("^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "ID",
|
||||
Code = "id_npwp",
|
||||
Description = "Indonesian NPWP number",
|
||||
Example = "012.345.678.9-012.345",
|
||||
ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}.?[0-9]{1}-?[0-9]{3}.?[0-9]{3}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "IE",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Ireland)",
|
||||
Example = "IE1234567AB",
|
||||
ValidationExpression = new Regex("^IE[0-9]{7}[A-Z]{1,2}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "IL",
|
||||
Code = "il_vat",
|
||||
Description = "Israel VAT",
|
||||
Example = "000012345",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "IT",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Italy)",
|
||||
Example = "IT12345678912",
|
||||
ValidationExpression = new Regex("^IT[0-9]{11}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "JP",
|
||||
Code = "jp_cn",
|
||||
Description = "Japanese Corporate Number (*Hōjin Bangō*)",
|
||||
Example = "1234567891234",
|
||||
ValidationExpression = new Regex("^[0-9]{13}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "JP",
|
||||
Code = "jp_rn",
|
||||
Description =
|
||||
"Japanese Registered Foreign Businesses' Registration Number (*Tōroku Kokugai Jigyōsha no Tōroku Bangō*)",
|
||||
Example = "12345",
|
||||
ValidationExpression = new Regex("^[0-9]{5}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "JP",
|
||||
Code = "jp_trn",
|
||||
Description = "Japanese Tax Registration Number (*Tōroku Bangō*)",
|
||||
Example = "T1234567891234",
|
||||
ValidationExpression = new Regex("^T[0-9]{13}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "KZ",
|
||||
Code = "kz_bin",
|
||||
Description = "Kazakhstani Business Identification Number",
|
||||
Example = "123456789012",
|
||||
ValidationExpression = new Regex("^[0-9]{12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "KE",
|
||||
Code = "ke_pin",
|
||||
Description = "Kenya Revenue Authority Personal Identification Number",
|
||||
Example = "P000111111A",
|
||||
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{9}[A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "LV",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number",
|
||||
Example = "LV12345678912",
|
||||
ValidationExpression = new Regex("^LV[0-9]{11}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "LI",
|
||||
Code = "li_uid",
|
||||
Description = "Liechtensteinian UID number",
|
||||
Example = "CHE123456789",
|
||||
ValidationExpression = new Regex("^CHE[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "LI",
|
||||
Code = "li_vat",
|
||||
Description = "Liechtensteinian VAT number",
|
||||
Example = "12345",
|
||||
ValidationExpression = new Regex("^[0-9]{5}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "LT",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Lithuania)",
|
||||
Example = "LT123456789123",
|
||||
ValidationExpression = new Regex("^LT[0-9]{9,12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "LU",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Luxembourg)",
|
||||
Example = "LU12345678",
|
||||
ValidationExpression = new Regex("^LU[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MY",
|
||||
Code = "my_frp",
|
||||
Description = "Malaysian FRP number",
|
||||
Example = "12345678",
|
||||
ValidationExpression = new Regex("^[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MY",
|
||||
Code = "my_itn",
|
||||
Description = "Malaysian ITN",
|
||||
Example = "C 1234567890",
|
||||
ValidationExpression = new Regex("^[A-Z]{1} ?[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MY",
|
||||
Code = "my_sst",
|
||||
Description = "Malaysian SST number",
|
||||
Example = "A12-3456-78912345",
|
||||
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{2}-?[0-9]{4}-?[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MT",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Malta)",
|
||||
Example = "MT12345678",
|
||||
ValidationExpression = new Regex("^MT[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MX",
|
||||
Code = "mx_rfc",
|
||||
Description = "Mexican RFC number",
|
||||
Example = "ABC010203AB9",
|
||||
ValidationExpression = new Regex("^[A-Z]{3}[0-9]{6}[A-Z0-9]{3}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MD",
|
||||
Code = "md_vat",
|
||||
Description = "Moldova VAT Number",
|
||||
Example = "1234567",
|
||||
ValidationExpression = new Regex("^[0-9]{7}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "MA",
|
||||
Code = "ma_vat",
|
||||
Description = "Morocco VAT Number",
|
||||
Example = "12345678",
|
||||
ValidationExpression = new Regex("^[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "NL",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Netherlands)",
|
||||
Example = "NL123456789B12",
|
||||
ValidationExpression = new Regex("^NL[0-9]{9}B[0-9]{2}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "NZ",
|
||||
Code = "nz_gst",
|
||||
Description = "New Zealand GST number",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "NG",
|
||||
Code = "ng_tin",
|
||||
Description = "Nigerian TIN Number",
|
||||
Example = "12345678-0001",
|
||||
ValidationExpression = new Regex("^[0-9]{8}-[0-9]{4}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "NO",
|
||||
Code = "no_vat",
|
||||
Description = "Norwegian VAT number",
|
||||
Example = "123456789MVA",
|
||||
ValidationExpression = new Regex("^[0-9]{9}MVA$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "NO",
|
||||
Code = "no_voec",
|
||||
Description = "Norwegian VAT on e-commerce number",
|
||||
Example = "1234567",
|
||||
ValidationExpression = new Regex("^[0-9]{7}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "OM",
|
||||
Code = "om_vat",
|
||||
Description = "Omani VAT Number",
|
||||
Example = "OM1234567890",
|
||||
ValidationExpression = new Regex("^OM[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "PE",
|
||||
Code = "pe_ruc",
|
||||
Description = "Peruvian RUC number",
|
||||
Example = "12345678901",
|
||||
ValidationExpression = new Regex("^[0-9]{11}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "PH",
|
||||
Code = "ph_tin",
|
||||
Description = "Philippines Tax Identification Number",
|
||||
Example = "123456789012",
|
||||
ValidationExpression = new Regex("^[0-9]{12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "PL",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Poland)",
|
||||
Example = "PL1234567890",
|
||||
ValidationExpression = new Regex("^PL[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "PT",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Portugal)",
|
||||
Example = "PT123456789",
|
||||
ValidationExpression = new Regex("^PT[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "RO",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Romania)",
|
||||
Example = "RO1234567891",
|
||||
ValidationExpression = new Regex("^RO[0-9]{2,10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "RO",
|
||||
Code = "ro_tin",
|
||||
Description = "Romanian tax ID number",
|
||||
Example = "1234567890123",
|
||||
ValidationExpression = new Regex("^[0-9]{13}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "RU",
|
||||
Code = "ru_inn",
|
||||
Description = "Russian INN",
|
||||
Example = "1234567891",
|
||||
ValidationExpression = new Regex("^[0-9]{10,12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "RU",
|
||||
Code = "ru_kpp",
|
||||
Description = "Russian KPP",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SA",
|
||||
Code = "sa_vat",
|
||||
Description = "Saudi Arabia VAT",
|
||||
Example = "123456789012345",
|
||||
ValidationExpression = new Regex("^[0-9]{15}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "RS",
|
||||
Code = "rs_pib",
|
||||
Description = "Serbian PIB number",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SG",
|
||||
Code = "sg_gst",
|
||||
Description = "Singaporean GST",
|
||||
Example = "M12345678X",
|
||||
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}[A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SG",
|
||||
Code = "sg_uen",
|
||||
Description = "Singaporean UEN",
|
||||
Example = "123456789F",
|
||||
ValidationExpression = new Regex("^[0-9]{9}[A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SK",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Slovakia)",
|
||||
Example = "SK1234567891",
|
||||
ValidationExpression = new Regex("^SK[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SI",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Slovenia)",
|
||||
Example = "SI12345678",
|
||||
ValidationExpression = new Regex("^SI[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SI",
|
||||
Code = "si_tin",
|
||||
Description = "Slovenia tax number (davčna številka)",
|
||||
Example = "12345678",
|
||||
ValidationExpression = new Regex("^[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "ZA",
|
||||
Code = "za_vat",
|
||||
Description = "South African VAT number",
|
||||
Example = "4123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "KR",
|
||||
Code = "kr_brn",
|
||||
Description = "Korean BRN",
|
||||
Example = "123-45-67890",
|
||||
ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{2}-?[0-9]{5}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "ES",
|
||||
Code = "es_cif",
|
||||
Description = "Spanish NIF/CIF number",
|
||||
Example = "A12345678",
|
||||
ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "ES",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Spain)",
|
||||
Example = "ESA1234567Z",
|
||||
ValidationExpression = new Regex("^ES[A-Z]{1}[0-9]{7}[A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "SE",
|
||||
Code = "eu_vat",
|
||||
Description = "European VAT number (Sweden)",
|
||||
Example = "SE123456789123",
|
||||
ValidationExpression = new Regex("^SE[0-9]{12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CH",
|
||||
Code = "ch_uid",
|
||||
Description = "Switzerland UID number",
|
||||
Example = "CHE-123.456.789 HR",
|
||||
ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?HR$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "CH",
|
||||
Code = "ch_vat",
|
||||
Description = "Switzerland VAT number",
|
||||
Example = "CHE-123.456.789 MWST",
|
||||
ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?MWST$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "TW",
|
||||
Code = "tw_vat",
|
||||
Description = "Taiwanese VAT",
|
||||
Example = "12345678",
|
||||
ValidationExpression = new Regex("^[0-9]{8}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "TZ",
|
||||
Code = "tz_vat",
|
||||
Description = "Tanzania VAT Number",
|
||||
Example = "12345678A",
|
||||
ValidationExpression = new Regex("^[0-9]{8}[A-Z]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "TH",
|
||||
Code = "th_vat",
|
||||
Description = "Thai VAT",
|
||||
Example = "1234567891234",
|
||||
ValidationExpression = new Regex("^[0-9]{13}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "TR",
|
||||
Code = "tr_tin",
|
||||
Description = "Turkish TIN Number",
|
||||
Example = "0123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{10}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "UA",
|
||||
Code = "ua_vat",
|
||||
Description = "Ukrainian VAT",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "AE",
|
||||
Code = "ae_trn",
|
||||
Description = "United Arab Emirates TRN",
|
||||
Example = "123456789012345",
|
||||
ValidationExpression = new Regex("^[0-9]{15}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "GB",
|
||||
Code = "eu_vat",
|
||||
Description = "Northern Ireland VAT number",
|
||||
Example = "XI123456789",
|
||||
ValidationExpression = new Regex("^XI[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "GB",
|
||||
Code = "gb_vat",
|
||||
Description = "United Kingdom VAT number",
|
||||
Example = "GB123456789",
|
||||
ValidationExpression = new Regex("^GB[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "US",
|
||||
Code = "us_ein",
|
||||
Description = "United States EIN",
|
||||
Example = "12-3456789",
|
||||
ValidationExpression = new Regex("^[0-9]{2}-?[0-9]{7}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "UY",
|
||||
Code = "uy_ruc",
|
||||
Description = "Uruguayan RUC number",
|
||||
Example = "123456789012",
|
||||
ValidationExpression = new Regex("^[0-9]{12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "UZ",
|
||||
Code = "uz_tin",
|
||||
Description = "Uzbekistan TIN Number",
|
||||
Example = "123456789",
|
||||
ValidationExpression = new Regex("^[0-9]{9}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "UZ",
|
||||
Code = "uz_vat",
|
||||
Description = "Uzbekistan VAT Number",
|
||||
Example = "123456789012",
|
||||
ValidationExpression = new Regex("^[0-9]{12}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "VE",
|
||||
Code = "ve_rif",
|
||||
Description = "Venezuelan RIF number",
|
||||
Example = "A-12345678-9",
|
||||
ValidationExpression = new Regex("^[A-Z]{1}-?[0-9]{8}-?[0-9]{1}$")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Country = "VN",
|
||||
Code = "vn_tin",
|
||||
Description = "Vietnamese tax ID number",
|
||||
Example = "1234567890",
|
||||
ValidationExpression = new Regex("^[0-9]{10}$")
|
||||
}
|
||||
];
|
||||
|
||||
public string GetStripeTaxCode(string country, string taxId)
|
||||
{
|
||||
foreach (var taxIdType in _taxIdTypes.Where(x => x.Country == country))
|
||||
{
|
||||
if (taxIdType.ValidationExpression.IsMatch(taxId))
|
||||
{
|
||||
return taxIdType.Code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsSupported(string country)
|
||||
{
|
||||
return _taxIdTypes.Any(x => x.Country == country);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -164,6 +164,8 @@ 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 const string RecordInstallationLastActivityDate = "installation-last-activity-date";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
@ -25,6 +25,12 @@
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.64" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.29.2" />
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.67.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.68.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" />
|
||||
@ -44,12 +50,13 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.10" />
|
||||
<PackageReference Include="OneOf" Version="3.0.271" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
|
||||
<PackageReference Include="Sentry.Serilog" Version="3.41.4" />
|
||||
<PackageReference Include="Sentry.Serilog" Version="5.0.0" />
|
||||
<PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
|
||||
@ -62,6 +69,10 @@
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Billing\Pricing\Protos\password-manager.proto" GrpcServices="Client" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Resources\" />
|
||||
<Folder Include="Properties\" />
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -0,0 +1,14 @@
|
||||
namespace Bit.Core.Platform.Installations;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface responsible for updating data on an `Installation`
|
||||
/// record.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is implemented by `UpdateInstallationCommand`
|
||||
/// </remarks>
|
||||
/// <seealso cref="Bit.Core.Platform.Installations.UpdateInstallationCommand"/>
|
||||
public interface IUpdateInstallationCommand
|
||||
{
|
||||
Task UpdateLastActivityDateAsync(Guid installationId);
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
namespace Bit.Core.Platform.Installations;
|
||||
|
||||
/// <summary>
|
||||
/// Commands responsible for updating an installation from
|
||||
/// `InstallationRepository`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If referencing: you probably want the interface
|
||||
/// `IUpdateInstallationCommand` instead of directly calling this class.
|
||||
/// </remarks>
|
||||
/// <seealso cref="IUpdateInstallationCommand"/>
|
||||
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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The base entity for the SQL table `dbo.Installation`. Used to store
|
||||
/// information pertinent to self hosted Bitwarden installations.
|
||||
/// </summary>
|
||||
public class Installation : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
@ -14,6 +19,7 @@ public class Installation : ITableObject<Guid>
|
||||
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()
|
||||
{
|
@ -0,0 +1,30 @@
|
||||
namespace Bit.Core.Platform.Installations;
|
||||
|
||||
/// <summary>
|
||||
/// Queries responsible for fetching an installation from
|
||||
/// `InstallationRepository`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If referencing: you probably want the interface `IGetInstallationQuery`
|
||||
/// instead of directly calling this class.
|
||||
/// </remarks>
|
||||
/// <seealso cref="IGetInstallationQuery"/>
|
||||
public class GetInstallationQuery : IGetInstallationQuery
|
||||
{
|
||||
private readonly IInstallationRepository _installationRepository;
|
||||
|
||||
public GetInstallationQuery(IInstallationRepository installationRepository)
|
||||
{
|
||||
_installationRepository = installationRepository;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IGetInstallationQuery.GetByIdAsync"/>
|
||||
public async Task<Installation> GetByIdAsync(Guid installationId)
|
||||
{
|
||||
if (installationId == default(Guid))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await _installationRepository.GetByIdAsync(installationId);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
namespace Bit.Core.Platform.Installations;
|
||||
|
||||
/// <summary>
|
||||
/// Query interface responsible for fetching an installation from
|
||||
/// `InstallationRepository`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is implemented by `GetInstallationQuery`
|
||||
/// </remarks>
|
||||
/// <seealso cref="GetInstallationQuery"/>
|
||||
public interface IGetInstallationQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves an installation from the `InstallationRepository` by its id.
|
||||
/// </summary>
|
||||
/// <param name="installationId">The GUID id of the installation.</param>
|
||||
/// <returns>A task containing an `Installation`.</returns>
|
||||
/// <seealso cref="T:Bit.Core.Platform.Installations.Repositories.IInstallationRepository"/>
|
||||
Task<Installation> GetByIdAsync(Guid installationId);
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Platform.Installations;
|
||||
|
||||
/// <summary>
|
||||
/// The CRUD repository interface for communicating with `dbo.Installation`,
|
||||
/// which is used to store information pertinent to self-hosted
|
||||
/// installations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is implemented by `InstallationRepository` in the Dapper
|
||||
/// and Entity Framework projects.
|
||||
/// </remarks>
|
||||
/// <seealso cref="T:Bit.Infrastructure.Dapper.Platform.Installations.Repositories.InstallationRepository"/>
|
||||
public interface IInstallationRepository : IRepository<Installation, Guid>
|
||||
{
|
||||
}
|
19
src/Core/Platform/PlatformServiceCollectionExtensions.cs
Normal file
19
src/Core/Platform/PlatformServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using Bit.Core.Platform.Installations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Platform;
|
||||
|
||||
public static class PlatformServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extend DI to include commands and queries exported from the Platform
|
||||
/// domain.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPlatformServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IGetInstallationQuery, GetInstallationQuery>();
|
||||
services.AddScoped<IUpdateInstallationCommand, UpdateInstallationCommand>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
@ -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
|
||||
{
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
namespace Bit.Core.Platform.Push;
|
||||
|
||||
public interface IPushRegistrationService
|
||||
{
|
@ -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
|
||||
{
|
@ -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
|
||||
{
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
namespace Bit.Core.Platform.Push.Internal;
|
||||
|
||||
public class NoopPushRegistrationService : IPushRegistrationService
|
||||
{
|
@ -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
|
||||
{
|
@ -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
|
||||
{
|
@ -1,14 +1,14 @@
|
||||
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
|
||||
{
|
||||
|
||||
public RelayPushRegistrationService(
|
||||
IHttpClientFactory httpFactory,
|
||||
GlobalSettings globalSettings,
|
@ -1,9 +0,0 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IInstallationRepository : IRepository<Installation, Guid>
|
||||
{
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
||||
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
||||
using Bit.Core.Billing.Models.Api.Responses;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -59,4 +62,7 @@ public interface IPaymentService
|
||||
Task<bool> RisksSubscriptionFailure(Organization organization);
|
||||
Task<bool> HasSecretsManagerStandalone(Organization organization);
|
||||
Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription);
|
||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ public interface IStripeAdapter
|
||||
Task<Stripe.Invoice> InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options);
|
||||
Task<Stripe.Invoice> InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options);
|
||||
Task<List<Stripe.Invoice>> InvoiceListAsync(StripeInvoiceListOptions options);
|
||||
Task<Stripe.Invoice> InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options);
|
||||
Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options);
|
||||
Task<Stripe.Invoice> InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options);
|
||||
Task<Stripe.Invoice> InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options);
|
||||
@ -42,6 +43,7 @@ public interface IStripeAdapter
|
||||
IAsyncEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options);
|
||||
Task<Stripe.PaymentMethod> PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null);
|
||||
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
|
||||
Task<Stripe.Plan> PlanGetAsync(string id, Stripe.PlanGetOptions options = null);
|
||||
Task<Stripe.TaxRate> TaxRateCreateAsync(Stripe.TaxRateCreateOptions options);
|
||||
Task<Stripe.TaxRate> TaxRateUpdateAsync(string id, Stripe.TaxRateUpdateOptions options);
|
||||
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);
|
||||
|
@ -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;
|
||||
|
@ -15,6 +15,7 @@ public class StripeAdapter : IStripeAdapter
|
||||
private readonly Stripe.RefundService _refundService;
|
||||
private readonly Stripe.CardService _cardService;
|
||||
private readonly Stripe.BankAccountService _bankAccountService;
|
||||
private readonly Stripe.PlanService _planService;
|
||||
private readonly Stripe.PriceService _priceService;
|
||||
private readonly Stripe.SetupIntentService _setupIntentService;
|
||||
private readonly Stripe.TestHelpers.TestClockService _testClockService;
|
||||
@ -33,6 +34,7 @@ public class StripeAdapter : IStripeAdapter
|
||||
_cardService = new Stripe.CardService();
|
||||
_bankAccountService = new Stripe.BankAccountService();
|
||||
_priceService = new Stripe.PriceService();
|
||||
_planService = new Stripe.PlanService();
|
||||
_setupIntentService = new SetupIntentService();
|
||||
_testClockService = new Stripe.TestHelpers.TestClockService();
|
||||
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
|
||||
@ -133,6 +135,11 @@ public class StripeAdapter : IStripeAdapter
|
||||
return invoices;
|
||||
}
|
||||
|
||||
public Task<Invoice> InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options)
|
||||
{
|
||||
return _invoiceService.CreatePreviewAsync(options);
|
||||
}
|
||||
|
||||
public async Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options)
|
||||
=> (await _invoiceService.SearchAsync(options)).Data;
|
||||
|
||||
@ -184,6 +191,11 @@ public class StripeAdapter : IStripeAdapter
|
||||
return _paymentMethodService.DetachAsync(id, options);
|
||||
}
|
||||
|
||||
public Task<Stripe.Plan> PlanGetAsync(string id, Stripe.PlanGetOptions options = null)
|
||||
{
|
||||
return _planService.GetAsync(id, options);
|
||||
}
|
||||
|
||||
public Task<Stripe.TaxRate> TaxRateCreateAsync(Stripe.TaxRateCreateOptions options)
|
||||
{
|
||||
return _taxRateService.CreateAsync(options);
|
||||
|
@ -1,8 +1,13 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
||||
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
||||
using Bit.Core.Billing.Models.Api.Responses;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -32,6 +37,8 @@ public class StripePaymentService : IPaymentService
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITaxService _taxService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
|
||||
public StripePaymentService(
|
||||
ITransactionRepository transactionRepository,
|
||||
@ -40,7 +47,9 @@ public class StripePaymentService : IPaymentService
|
||||
IStripeAdapter stripeAdapter,
|
||||
Braintree.IBraintreeGateway braintreeGateway,
|
||||
IGlobalSettings globalSettings,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
ITaxService taxService,
|
||||
ISubscriberService subscriberService)
|
||||
{
|
||||
_transactionRepository = transactionRepository;
|
||||
_logger = logger;
|
||||
@ -49,6 +58,8 @@ public class StripePaymentService : IPaymentService
|
||||
_btGateway = braintreeGateway;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_taxService = taxService;
|
||||
_subscriberService = subscriberService;
|
||||
}
|
||||
|
||||
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
|
||||
@ -112,6 +123,20 @@ public class StripePaymentService : IPaymentService
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
if (taxInfo.TaxIdNumber != null && taxInfo.TaxIdType == null)
|
||||
{
|
||||
taxInfo.TaxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
|
||||
if (taxInfo.TaxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
}
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Description = org.DisplayBusinessName(),
|
||||
@ -146,12 +171,9 @@ public class StripePaymentService : IPaymentService
|
||||
City = taxInfo?.BillingAddressCity,
|
||||
State = taxInfo?.BillingAddressState,
|
||||
},
|
||||
TaxIdData = taxInfo?.HasTaxId != true
|
||||
? null
|
||||
:
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, }
|
||||
],
|
||||
TaxIdData = taxInfo.HasTaxId
|
||||
? [new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }]
|
||||
: null
|
||||
};
|
||||
|
||||
customerCreateOptions.AddExpand("tax");
|
||||
@ -1372,6 +1394,12 @@ public class StripePaymentService : IPaymentService
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
|
||||
{
|
||||
taxInfo.TaxIdType = taxInfo.TaxIdType ??
|
||||
_taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber);
|
||||
}
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions
|
||||
@ -1401,8 +1429,17 @@ public class StripePaymentService : IPaymentService
|
||||
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
||||
Line2 = taxInfo.BillingAddressLine2,
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState,
|
||||
State = taxInfo.BillingAddressState
|
||||
},
|
||||
TaxIdData = string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)
|
||||
? []
|
||||
: [
|
||||
new CustomerTaxIdDataOptions
|
||||
{
|
||||
Type = taxInfo.TaxIdType,
|
||||
Value = taxInfo.TaxIdNumber
|
||||
}
|
||||
],
|
||||
Expand = ["sources", "tax", "subscriptions"],
|
||||
});
|
||||
|
||||
@ -1458,6 +1495,8 @@ public class StripePaymentService : IPaymentService
|
||||
await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new PaymentMethodDetachOptions());
|
||||
}
|
||||
|
||||
await _subscriberService.UpdateTaxInformation(subscriber, TaxInformation.From(taxInfo));
|
||||
|
||||
customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
{
|
||||
Metadata = stripeCustomerMetadata,
|
||||
@ -1474,15 +1513,6 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
]
|
||||
},
|
||||
Address = taxInfo == null ? null : new AddressOptions
|
||||
{
|
||||
Country = taxInfo.BillingAddressCountry,
|
||||
PostalCode = taxInfo.BillingAddressPostalCode,
|
||||
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
||||
Line2 = taxInfo.BillingAddressLine2,
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState,
|
||||
},
|
||||
Expand = ["tax", "subscriptions"]
|
||||
});
|
||||
}
|
||||
@ -1659,6 +1689,7 @@ public class StripePaymentService : IPaymentService
|
||||
return new TaxInfo
|
||||
{
|
||||
TaxIdNumber = taxId?.Value,
|
||||
TaxIdType = taxId?.Type,
|
||||
BillingAddressLine1 = address?.Line1,
|
||||
BillingAddressLine2 = address?.Line2,
|
||||
BillingAddressCity = address?.City,
|
||||
@ -1670,9 +1701,13 @@ public class StripePaymentService : IPaymentService
|
||||
|
||||
public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo)
|
||||
{
|
||||
if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser())
|
||||
{
|
||||
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions
|
||||
return;
|
||||
}
|
||||
|
||||
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
@ -1686,23 +1721,59 @@ public class StripePaymentService : IPaymentService
|
||||
Expand = ["tax_ids"]
|
||||
});
|
||||
|
||||
if (!subscriber.IsUser() && customer != null)
|
||||
{
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
if (customer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (taxId != null)
|
||||
{
|
||||
await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) &&
|
||||
!string.IsNullOrWhiteSpace(taxInfo.TaxIdType))
|
||||
{
|
||||
await _stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions
|
||||
{
|
||||
Type = taxInfo.TaxIdType,
|
||||
Value = taxInfo.TaxIdNumber,
|
||||
});
|
||||
}
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
|
||||
if (taxId != null)
|
||||
{
|
||||
await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taxIdType = taxInfo.TaxIdType;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(taxIdType))
|
||||
{
|
||||
taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||
new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, });
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
taxInfo.TaxIdNumber,
|
||||
taxInfo.BillingAddressCountry);
|
||||
throw new BadRequestException("billingInvalidTaxIdError");
|
||||
default:
|
||||
_logger.LogError(e,
|
||||
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
|
||||
taxInfo.TaxIdNumber,
|
||||
taxInfo.BillingAddressCountry,
|
||||
customer.Id);
|
||||
throw new BadRequestException("billingTaxIdCreationError");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1835,6 +1906,285 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
|
||||
PreviewIndividualInvoiceRequestBody parameters,
|
||||
string gatewayCustomerId,
|
||||
string gatewaySubscriptionId)
|
||||
{
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
{
|
||||
AutomaticTax = new InvoiceAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true,
|
||||
},
|
||||
Currency = "usd",
|
||||
Discounts = new List<InvoiceDiscountOptions>(),
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Quantity = 1,
|
||||
Plan = "premium-annually"
|
||||
},
|
||||
|
||||
new()
|
||||
{
|
||||
Quantity = parameters.PasswordManager.AdditionalStorage,
|
||||
Plan = "storage-gb-annually"
|
||||
}
|
||||
]
|
||||
},
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
PostalCode = parameters.TaxInformation.PostalCode,
|
||||
Country = parameters.TaxInformation.Country,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId))
|
||||
{
|
||||
var taxIdType = _taxService.GetStripeTaxCode(
|
||||
options.CustomerDetails.Address.Country,
|
||||
parameters.TaxInformation.TaxId);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvalidTaxIdError");
|
||||
}
|
||||
|
||||
options.CustomerDetails.TaxIds = [
|
||||
new InvoiceCustomerDetailsTaxIdOptions
|
||||
{
|
||||
Type = taxIdType,
|
||||
Value = parameters.TaxInformation.TaxId
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (gatewayCustomerId != null)
|
||||
{
|
||||
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||
|
||||
if (gatewayCustomer.Discount != null)
|
||||
{
|
||||
options.Discounts.Add(new InvoiceDiscountOptions
|
||||
{
|
||||
Discount = gatewayCustomer.Discount.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (gatewaySubscriptionId != null)
|
||||
{
|
||||
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
|
||||
|
||||
if (gatewaySubscription?.Discount != null)
|
||||
{
|
||||
options.Discounts.Add(new InvoiceDiscountOptions
|
||||
{
|
||||
Discount = gatewaySubscription.Discount.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||
|
||||
var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null
|
||||
? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
|
||||
: 0M;
|
||||
|
||||
var result = new PreviewInvoiceResponseModel(
|
||||
effectiveTaxRate,
|
||||
invoice.TotalExcludingTax.ToMajor() ?? 0,
|
||||
invoice.Tax.ToMajor() ?? 0,
|
||||
invoice.Total.ToMajor());
|
||||
return result;
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvalidTaxIdError");
|
||||
default:
|
||||
_logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvoiceError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
|
||||
PreviewOrganizationInvoiceRequestBody parameters,
|
||||
string gatewayCustomerId,
|
||||
string gatewaySubscriptionId)
|
||||
{
|
||||
var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan);
|
||||
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
{
|
||||
AutomaticTax = new InvoiceAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true,
|
||||
},
|
||||
Currency = "usd",
|
||||
Discounts = new List<InvoiceDiscountOptions>(),
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Quantity = parameters.PasswordManager.AdditionalStorage,
|
||||
Plan = plan.PasswordManager.StripeStoragePlanId
|
||||
}
|
||||
]
|
||||
},
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
PostalCode = parameters.TaxInformation.PostalCode,
|
||||
Country = parameters.TaxInformation.Country,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (plan.PasswordManager.HasAdditionalSeatsOption)
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(
|
||||
new()
|
||||
{
|
||||
Quantity = parameters.PasswordManager.Seats,
|
||||
Plan = plan.PasswordManager.StripeSeatPlanId
|
||||
}
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(
|
||||
new()
|
||||
{
|
||||
Quantity = 1,
|
||||
Plan = plan.PasswordManager.StripePlanId
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (plan.SupportsSecretsManager)
|
||||
{
|
||||
if (plan.SecretsManager.HasAdditionalSeatsOption)
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(new()
|
||||
{
|
||||
Quantity = parameters.SecretsManager?.Seats ?? 0,
|
||||
Plan = plan.SecretsManager.StripeSeatPlanId
|
||||
});
|
||||
}
|
||||
|
||||
if (plan.SecretsManager.HasAdditionalServiceAccountOption)
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(new()
|
||||
{
|
||||
Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0,
|
||||
Plan = plan.SecretsManager.StripeServiceAccountPlanId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId))
|
||||
{
|
||||
var taxIdType = _taxService.GetStripeTaxCode(
|
||||
options.CustomerDetails.Address.Country,
|
||||
parameters.TaxInformation.TaxId);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
|
||||
options.CustomerDetails.TaxIds = [
|
||||
new InvoiceCustomerDetailsTaxIdOptions
|
||||
{
|
||||
Type = taxIdType,
|
||||
Value = parameters.TaxInformation.TaxId
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (gatewayCustomerId != null)
|
||||
{
|
||||
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||
|
||||
if (gatewayCustomer.Discount != null)
|
||||
{
|
||||
options.Discounts.Add(new InvoiceDiscountOptions
|
||||
{
|
||||
Discount = gatewayCustomer.Discount.Id
|
||||
});
|
||||
}
|
||||
|
||||
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
|
||||
|
||||
if (gatewaySubscription?.Discount != null)
|
||||
{
|
||||
options.Discounts.Add(new InvoiceDiscountOptions
|
||||
{
|
||||
Discount = gatewaySubscription.Discount.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||
|
||||
var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null
|
||||
? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
|
||||
: 0M;
|
||||
|
||||
var result = new PreviewInvoiceResponseModel(
|
||||
effectiveTaxRate,
|
||||
invoice.TotalExcludingTax.ToMajor() ?? 0,
|
||||
invoice.Tax.ToMajor() ?? 0,
|
||||
invoice.Total.ToMajor());
|
||||
return result;
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvalidTaxIdError");
|
||||
default:
|
||||
_logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvoiceError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PaymentMethod GetLatestCardPaymentMethod(string customerId)
|
||||
{
|
||||
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(
|
||||
|
@ -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;
|
||||
@ -973,6 +974,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
await paymentService.CancelAndRecoverChargesAsync(user);
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return new Tuple<bool, string>(string.IsNullOrWhiteSpace(paymentIntentClientSecret),
|
||||
paymentIntentClientSecret);
|
||||
}
|
||||
@ -1140,7 +1144,10 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
? new UserLicense(user, _licenseService)
|
||||
: new UserLicense(user, subscriptionInfo, _licenseService);
|
||||
|
||||
userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo);
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor))
|
||||
{
|
||||
userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo);
|
||||
}
|
||||
|
||||
return userLicense;
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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<CustomTokenReque
|
||||
ICustomTokenRequestValidator
|
||||
{
|
||||
private readonly UserManager<User> _userManager;
|
||||
private readonly IUpdateInstallationCommand _updateInstallationCommand;
|
||||
|
||||
public CustomTokenRequestValidator(
|
||||
UserManager<User> userManager,
|
||||
@ -39,7 +42,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
IPolicyService policyService,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IUpdateInstallationCommand updateInstallationCommand
|
||||
)
|
||||
: base(
|
||||
userManager,
|
||||
@ -59,6 +63,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
userDecryptionOptionsBuilder)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_updateInstallationCommand = updateInstallationCommand;
|
||||
}
|
||||
|
||||
public async Task ValidateAsync(CustomTokenRequestValidationContext context)
|
||||
@ -76,16 +81,24 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
}
|
||||
|
||||
string[] allowedGrantTypes = ["authorization_code", "client_credentials"];
|
||||
string clientId = context.Result.ValidatedRequest.ClientId;
|
||||
if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType)
|
||||
|| context.Result.ValidatedRequest.ClientId.StartsWith("organization")
|
||||
|| context.Result.ValidatedRequest.ClientId.StartsWith("installation")
|
||||
|| context.Result.ValidatedRequest.ClientId.StartsWith("internal")
|
||||
|| clientId.StartsWith("organization")
|
||||
|| clientId.StartsWith("installation")
|
||||
|| clientId.StartsWith("internal")
|
||||
|| context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets))
|
||||
{
|
||||
if (context.Result.ValidatedRequest.Client.Properties.TryGetValue("encryptedPayload", out var payload) &&
|
||||
!string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
context.Result.CustomResponse = new Dictionary<string, object> { { "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<CustomTokenReque
|
||||
context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl;
|
||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@ -202,4 +216,25 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
context.Result.ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription;
|
||||
context.Result.CustomResponse = requestContext.CustomResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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!
|
||||
/// </remarks>
|
||||
private async Task RecordActivityForInstallation(string? installationIdString)
|
||||
{
|
||||
if (!Guid.TryParse(installationIdString, out var installationId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
await _updateInstallationCommand.UpdateLastActivityDateAsync(installationId);
|
||||
}
|
||||
}
|
||||
|
@ -44,8 +44,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
|
||||
IFeatureService featureService,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
|
||||
)
|
||||
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The CRUD repository for communicating with `dbo.Installation`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If referencing: you probably want the interface `IInstallationRepository`
|
||||
/// instead of directly calling this class.
|
||||
/// </remarks>
|
||||
/// <seealso cref="IInstallationRepository"/>
|
||||
public class InstallationRepository : Repository<Installation, Guid>, IInstallationRepository
|
||||
{
|
||||
public InstallationRepository(GlobalSettings globalSettings)
|
@ -48,6 +48,7 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery<ProviderUserOrgan
|
||||
LimitCollectionDeletion = x.o.LimitCollectionDeletion,
|
||||
AllowAdminAccessToAllCollectionItems = x.o.AllowAdminAccessToAllCollectionItems,
|
||||
UseRiskInsights = x.o.UseRiskInsights,
|
||||
ProviderType = x.p.Type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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<Core.Entities.Installation, Installation>()
|
||||
CreateMap<C.Installation, Installation>()
|
||||
// Shadow property - to be introduced by https://bitwarden.atlassian.net/browse/PM-11129
|
||||
.ForMember(i => i.LastActivityDate, opt => opt.Ignore())
|
||||
.ReverseMap();
|
||||
CreateMap<Core.Entities.Installation, Installation>().ReverseMap();
|
||||
CreateMap<C.Installation, Installation>().ReverseMap();
|
||||
}
|
||||
}
|
@ -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.Installation, Ef.Installation, Guid>, C.IInstallationRepository
|
||||
{
|
||||
public InstallationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Installations)
|
||||
{ }
|
||||
}
|
@ -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;
|
||||
|
@ -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<Core.Entities.Installation, Installation, Guid>, IInstallationRepository
|
||||
{
|
||||
public InstallationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Installations)
|
||||
{ }
|
||||
}
|
@ -30,6 +30,9 @@ 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;
|
||||
using Bit.Core.Resources;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
@ -124,6 +127,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddReportingServices();
|
||||
services.AddKeyManagementServices();
|
||||
services.AddNotificationCenterServices();
|
||||
services.AddPlatformServices();
|
||||
}
|
||||
|
||||
public static void AddTokenizers(this IServiceCollection services)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user