diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5077f1ba32..19eea71b6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -636,7 +636,9 @@ jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment - needs: build-docker + needs: + - build-artifacts + - build-docker if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 36a5f2c0a9..8dee75c7c2 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 22a2e93642..4af0e12e64 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -7,14 +7,12 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.Extensions.DependencyInjection; using Stripe; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -24,7 +22,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly IEventService _eventService; private readonly IMailService _mailService; private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IStripeAdapter _stripeAdapter; private readonly IFeatureService _featureService; @@ -32,26 +29,22 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly ISubscriberService _subscriberService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; - private readonly IAutomaticTaxStrategy _automaticTaxStrategy; public RemoveOrganizationFromProviderCommand( IEventService eventService, IMailService mailService, IOrganizationRepository organizationRepository, - IOrganizationService organizationService, IProviderOrganizationRepository providerOrganizationRepository, IStripeAdapter stripeAdapter, IFeatureService featureService, IProviderBillingService providerBillingService, ISubscriberService subscriberService, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPricingClient pricingClient, - [FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy) + IPricingClient pricingClient) { _eventService = eventService; _mailService = mailService; _organizationRepository = organizationRepository; - _organizationService = organizationService; _providerOrganizationRepository = providerOrganizationRepository; _stripeAdapter = stripeAdapter; _featureService = featureService; @@ -59,7 +52,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv _subscriberService = subscriberService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; - _automaticTaxStrategy = automaticTaxStrategy; } public async Task RemoveOrganizationFromProvider( @@ -77,7 +69,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( providerOrganization.OrganizationId, - Array.Empty(), + [], includeProvider: false)) { throw new BadRequestException("Organization must have at least one confirmed owner."); @@ -102,7 +94,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv /// /// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled /// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because - /// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly, + /// the provider's payment method will be removed from their Stripe customer, causing ensuing charges to fail. Lastly, /// we email the organization owners letting them know they need to add a new payment method. /// private async Task ResetOrganizationBillingAsync( @@ -142,15 +134,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] }; - if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { - _automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - else + else if (customer.HasRecognizedTaxLocation()) { - subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { - Enabled = true + Enabled = customer.Address.Country == "US" || + customer.TaxIds.Any() }; } @@ -187,7 +182,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _mailService.SendProviderUpdatePaymentMethod( organization.Id, organization.Name, - provider.Name, + provider.Name!, organizationOwnerEmails); } } diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 2fc44937a7..ad2d2d2aa1 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -5,12 +5,13 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -53,6 +54,7 @@ public class ProviderService : IProviderService private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; + private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, @@ -61,7 +63,8 @@ public class ProviderService : IProviderService IOrganizationRepository organizationRepository, GlobalSettings globalSettings, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, - IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient) + IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient, + IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -81,6 +84,7 @@ public class ProviderService : IProviderService _applicationCacheService = applicationCacheService; _providerBillingService = providerBillingService; _pricingClient = pricingClient; + _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) @@ -560,12 +564,12 @@ public class ProviderService : IProviderService ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan); - var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup); + var signUpResponse = await _providerClientOrganizationSignUpCommand.SignUpClientOrganizationAsync(organizationSignup); var providerOrganization = new ProviderOrganization { ProviderId = providerId, - OrganizationId = organization.Id, + OrganizationId = signUpResponse.Organization.Id, Key = organizationSignup.OwnerKey, }; @@ -574,12 +578,12 @@ public class ProviderService : IProviderService // Give the owner Can Manage access over the default collection // The orgUser is not available when the org is created so we have to do it here as part of the invite - var defaultOwnerAccess = defaultCollection != null + var defaultOwnerAccess = signUpResponse.DefaultCollection != null ? [ new CollectionAccessSelection { - Id = defaultCollection.Id, + Id = signUpResponse.DefaultCollection.Id, HidePasswords = false, ReadOnly = false, Manage = true @@ -587,7 +591,7 @@ public class ProviderService : IProviderService ] : Array.Empty(); - await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null, + await _organizationService.InviteUsersAsync(signUpResponse.Organization.Id, user.Id, systemUser: null, new (OrganizationUserInvite, string)[] { ( diff --git a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs similarity index 91% rename from bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs index c78e213c34..eea40577ad 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs @@ -1,8 +1,8 @@ using System.Globalization; -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using CsvHelper.Configuration.Attributes; -namespace Bit.Commercial.Core.Billing.Models; +namespace Bit.Commercial.Core.Billing.Providers.Models; public class ProviderClientInvoiceReportRow { diff --git a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs similarity index 98% rename from bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index d27b45af4a..8f6eb07fe1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -7,11 +7,12 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -24,7 +25,7 @@ using Microsoft.Extensions.Logging; using OneOf; using Stripe; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; [RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConverter( diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs similarity index 95% rename from bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index fe6b8d4617..8c90d778bc 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -1,5 +1,5 @@ using System.Globalization; -using Bit.Commercial.Core.Billing.Models; +using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -8,17 +8,17 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; -using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -27,15 +27,13 @@ using Bit.Core.Services; using Bit.Core.Settings; using Braintree; using CsvHelper; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; - using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; using Subscription = Stripe.Subscription; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; public class ProviderBillingService( IBraintreeGateway braintreeGateway, @@ -52,8 +50,7 @@ public class ProviderBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - ITaxService taxService, - [FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy) + ITaxService taxService) : IProviderBillingService { public async Task AddExistingOrganization( @@ -128,7 +125,7 @@ public class ProviderBillingService( /* * We have to scale the provider's seats before the ProviderOrganization - * row is inserted so the added organization's seats don't get double counted. + * row is inserted so the added organization's seats don't get double-counted. */ await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value); @@ -236,7 +233,7 @@ public class ProviderBillingService( var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions { - Expand = ["tax_ids"] + Expand = ["tax", "tax_ids"] }); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); @@ -284,6 +281,13 @@ public class ProviderBillingService( ] }; + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" }) + { + customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + } + var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); organization.GatewayCustomerId = customer.Id; @@ -520,6 +524,13 @@ public class ProviderBillingService( } }; + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US") + { + options.TaxExempt = StripeConstants.TaxExempt.Reverse; + } + if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) { var taxIdType = taxService.GetStripeTaxCode( @@ -531,6 +542,7 @@ public class ProviderBillingService( logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); + throw new BadRequestException("billingTaxIdTypeInferenceError"); } @@ -718,14 +730,21 @@ public class ProviderBillingService( TrialPeriodDays = trialPeriodDays }; - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); - } - else + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } + else if (customer.HasRecognizedTaxLocation()) + { + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = customer.Address.Country == "US" || + customer.TaxIds.Any() + }; + } try { diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs similarity index 99% rename from bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs index a9dbb6febf..8c55d31f2c 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs @@ -6,7 +6,7 @@ using Bit.Core.Billing; using Bit.Core.Billing.Enums; using Stripe; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; public static class ProviderPriceAdapter { diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 7f8c82e2c9..34f49e0ccc 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,9 +1,9 @@ using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Services; -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Microsoft.Extensions.DependencyInjection; namespace Bit.Commercial.Core.Utilities; diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 98ea72c69e..3e207c2d8a 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -18,8 +18,8 @@ "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } }, @@ -1106,13 +1106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -1754,16 +1747,6 @@ "dev": true, "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1898,9 +1881,9 @@ } }, "node_modules/sass-loader": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", - "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", "dev": true, "license": "MIT", "dependencies": { @@ -1939,9 +1922,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2193,16 +2176,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2225,14 +2198,15 @@ } }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.99.8", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", + "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -2249,9 +2223,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -2352,59 +2326,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 289612e79a..0cdd8871c0 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -17,8 +17,8 @@ "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index b450bf5d7f..5be18116c0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Commercial.Core.AdminConsole.Providers; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -7,8 +8,8 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -224,31 +225,115 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + options.Description == string.Empty && + options.Email == organization.BillingEmail && + options.Expand[0] == "tax" && + options.Expand[1] == "tax_ids")).Returns(new Customer + { + Id = "customer_id", + Address = new Address + { + Country = "US" + } + }); + stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription { Id = "subscription_id" }); - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == organization.GatewayCustomerId && - options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && - options.DaysUntilDue == 30 && - options.Metadata["organizationId"] == organization.Id.ToString() && - options.OffSession == true && - options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && - options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId && - options.Items.First().Quantity == organization.Seats) - , Arg.Any())) - .Do(x => + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + + await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => + options.Customer == organization.GatewayCustomerId && + options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && + options.DaysUntilDue == 30 && + options.AutomaticTax.Enabled == true && + options.Metadata["organizationId"] == organization.Id.ToString() && + options.OffSession == true && + options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId && + options.Items.First().Quantity == organization.Seats)); + + await sutProvider.GetDependency().Received(1) + .ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); + + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => + org.BillingEmail == "a@example.com" && + org.GatewaySubscriptionId == "subscription_id" && + org.Status == OrganizationStatusType.Created)); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1) + .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); + + await sutProvider.GetDependency().Received(1) + .SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_ReverseCharge_MakesCorrectInvocations( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + provider.Status = ProviderStatusType.Billable; + + providerOrganization.ProviderId = provider.Id; + + organization.Status = OrganizationStatusType.Managed; + + organization.PlanType = PlanType.TeamsMonthly; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + [], + includeProvider: false) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([ + "a@example.com", + "b@example.com" + ]); + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + options.Description == string.Empty && + options.Email == organization.BillingEmail && + options.Expand[0] == "tax" && + options.Expand[1] == "tax_ids")).Returns(new Customer { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions + Id = "customer_id", + Address = new Address { - Enabled = true - }; + Country = "US" + } }); + stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + { + Id = "subscription_id" + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index c66acfa8ce..cb8a9e8c69 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -6,11 +6,12 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -717,8 +718,8 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -755,8 +756,8 @@ public class ProviderServiceTests var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); await Assert.ThrowsAsync(() => sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user)); @@ -782,8 +783,8 @@ public class ProviderServiceTests var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -821,8 +822,8 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, defaultCollection)); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, defaultCollection)); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs similarity index 98% rename from bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs index 5d2d0a2c7c..c27d990213 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs @@ -1,16 +1,16 @@ #nullable enable using System.Text; -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -25,7 +25,7 @@ using NSubstitute; using Stripe; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; public class BusinessUnitConverterTests { diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs similarity index 88% rename from bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs index 2199bc4bfe..9af9a71cce 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Net; -using Bit.Commercial.Core.Billing; -using Bit.Commercial.Core.Billing.Models; +using Bit.Commercial.Core.Billing.Providers.Models; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -10,13 +10,13 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -40,7 +40,7 @@ using Customer = Stripe.Customer; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; [SutProviderCustomize] public class ProviderBillingServiceTests @@ -262,7 +262,7 @@ public class ProviderBillingServiceTests }; sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( - options => options.Expand.FirstOrDefault() == "tax_ids")) + options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids"))) .Returns(providerCustomer); sutProvider.GetDependency().BaseServiceUri @@ -312,6 +312,91 @@ public class ProviderBillingServiceTests org => org.GatewayCustomerId == "customer_id")); } + [Theory, BitAutoData] + public async Task CreateCustomer_ForClientOrg_ReverseCharge_Succeeds( + Provider provider, + Organization organization, + SutProvider sutProvider) + { + organization.GatewayCustomerId = null; + organization.Name = "Name"; + organization.BusinessName = "BusinessName"; + + var providerCustomer = new Customer + { + Address = new Address + { + Country = "CA", + PostalCode = "12345", + Line1 = "123 Main St.", + Line2 = "Unit 4", + City = "Fake Town", + State = "Fake State" + }, + TaxIds = new StripeList + { + Data = + [ + new TaxId { Type = "TYPE", Value = "VALUE" } + ] + } + }; + + sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( + options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids"))) + .Returns(providerCustomer); + + sutProvider.GetDependency().BaseServiceUri + .Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings()) + { + CloudRegion = "US" + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + + sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + options => + options.Address.Country == providerCustomer.Address.Country && + options.Address.PostalCode == providerCustomer.Address.PostalCode && + options.Address.Line1 == providerCustomer.Address.Line1 && + options.Address.Line2 == providerCustomer.Address.Line2 && + options.Address.City == providerCustomer.Address.City && + options.Address.State == providerCustomer.Address.State && + options.Name == organization.DisplayName() && + options.Description == $"{provider.Name} Client Organization" && + options.Email == provider.BillingEmail && + options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && + options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && + options.Metadata["region"] == "US" && + options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && + options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value && + options.TaxExempt == StripeConstants.TaxExempt.Reverse)) + .Returns(new Customer { Id = "customer_id" }); + + await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); + + await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + options => + options.Address.Country == providerCustomer.Address.Country && + options.Address.PostalCode == providerCustomer.Address.PostalCode && + options.Address.Line1 == providerCustomer.Address.Line1 && + options.Address.Line2 == providerCustomer.Address.Line2 && + options.Address.City == providerCustomer.Address.City && + options.Address.State == providerCustomer.Address.State && + options.Name == organization.DisplayName() && + options.Description == $"{provider.Name} Client Organization" && + options.Email == provider.BillingEmail && + options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && + options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && + options.Metadata["region"] == "US" && + options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && + options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value)); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + org => org.GatewayCustomerId == "customer_id")); + } + #endregion #region GenerateClientInvoiceReport @@ -1182,6 +1267,62 @@ public class ProviderBillingServiceTests Assert.Equivalent(expected, actual); } + [Theory, BitAutoData] + public async Task SetupCustomer_WithCard_ReverseCharge_Success( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var expected = new Customer + { + Id = "customer_id", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.PaymentMethod == tokenizedPaymentSource.Token && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && + o.TaxExempt == StripeConstants.TaxExempt.Reverse)) + .Returns(expected); + + var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + + Assert.Equivalent(expected, actual); + } + [Theory, BitAutoData] public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( SutProvider sutProvider, @@ -1307,7 +1448,7 @@ public class ProviderBillingServiceTests .Returns(new Customer { Id = "customer_id", - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + Address = new Address { Country = "US" } }); var providerPlans = new List @@ -1359,7 +1500,7 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + Address = new Address { Country = "US" } }; sutProvider.GetDependency() .GetCustomerOrThrow( @@ -1399,19 +1540,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => - { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && @@ -1443,11 +1571,11 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", + Address = new Address { Country = "US" }, InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = "pm_123" - }, - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + } }; sutProvider.GetDependency() @@ -1488,19 +1616,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => - { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); - sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); @@ -1536,9 +1651,9 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", + Address = new Address { Country = "US" }, InvoiceSettings = new CustomerInvoiceSettings(), - Metadata = new Dictionary(), - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + Metadata = new Dictionary() }; sutProvider.GetDependency() @@ -1579,19 +1694,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => - { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); - sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); @@ -1646,12 +1748,15 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", + Address = new Address + { + Country = "US" + }, InvoiceSettings = new CustomerInvoiceSettings(), Metadata = new Dictionary { ["btCustomerId"] = "braintree_customer_id" - }, - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + } }; sutProvider.GetDependency() @@ -1692,22 +1797,92 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sub => + sub.AutomaticTax.Enabled == true && + sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && + sub.Customer == "customer_id" && + sub.DaysUntilDue == null && + sub.Items.Count == 2 && + sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams && + sub.Items.ElementAt(0).Quantity == 100 && + sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise && + sub.Items.ElementAt(1).Quantity == 100 && + sub.Metadata["providerId"] == provider.Id.ToString() && + sub.OffSession == true && + sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + sub.TrialPeriodDays == 14)).Returns(expected); + + var actual = await sutProvider.Sut.SetupSubscription(provider); + + Assert.Equivalent(expected, actual); + } + + [Theory, BitAutoData] + public async Task SetupSubscription_ReverseCharge_Succeeds( + SutProvider sutProvider, + Provider provider) + { + provider.Type = ProviderType.Msp; + provider.GatewaySubscriptionId = null; + + var customer = new Customer + { + Id = "customer_id", + Address = new Address { Country = "CA" }, + InvoiceSettings = new CustomerInvoiceSettings { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); + DefaultPaymentMethodId = "pm_123" + } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow( + provider, + Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer); + + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.TeamsMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + }, + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + } + }; + + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + + sutProvider.GetDependency().GetByProviderId(provider.Id) + .Returns(providerPlans); + + var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs similarity index 97% rename from bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs index 9ecb4b0511..3087d5761c 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs @@ -1,11 +1,11 @@ -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Stripe; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; public class ProviderPriceAdapterTests { diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs similarity index 99% rename from bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs index 0a20b34818..f3164a14e0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs @@ -3,7 +3,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Tax; [SutProviderCustomize] public class TaxServiceTests diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 6eb81b5956..8cd2222dbf 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -11,7 +11,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index dd4332358c..b4abf81ee2 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -10,13 +10,13 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 44eebb8d7d..de9e25fa6f 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Enums; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index bcb96df006..2d4ba5012c 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Entities; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs index 2421710d41..be3a94949f 100644 --- a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs +++ b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs @@ -7,7 +7,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Utilities; diff --git a/src/Admin/Billing/Controllers/MigrateProvidersController.cs b/src/Admin/Billing/Controllers/MigrateProvidersController.cs index d4ef105e34..ef5ea2312e 100644 --- a/src/Admin/Billing/Controllers/MigrateProvidersController.cs +++ b/src/Admin/Billing/Controllers/MigrateProvidersController.cs @@ -1,8 +1,8 @@ using Bit.Admin.Billing.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; -using Bit.Core.Billing.Migration.Models; -using Bit.Core.Billing.Migration.Services; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Admin/Billing/Models/ProviderPlanViewModel.cs b/src/Admin/Billing/Models/ProviderPlanViewModel.cs index 7a50aba286..391c24d6df 100644 --- a/src/Admin/Billing/Models/ProviderPlanViewModel.cs +++ b/src/Admin/Billing/Models/ProviderPlanViewModel.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; namespace Bit.Admin.Billing.Models; diff --git a/src/Admin/Billing/Views/MigrateProviders/Details.cshtml b/src/Admin/Billing/Views/MigrateProviders/Details.cshtml index 303e6d2e45..6ee0344057 100644 --- a/src/Admin/Billing/Views/MigrateProviders/Details.cshtml +++ b/src/Admin/Billing/Views/MigrateProviders/Details.cshtml @@ -1,5 +1,5 @@ @using System.Text.Json -@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult +@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult @{ ViewData["Title"] = "Results"; } diff --git a/src/Admin/Billing/Views/MigrateProviders/Results.cshtml b/src/Admin/Billing/Views/MigrateProviders/Results.cshtml index 45611de80e..94db08db3d 100644 --- a/src/Admin/Billing/Views/MigrateProviders/Results.cshtml +++ b/src/Admin/Billing/Views/MigrateProviders/Results.cshtml @@ -1,4 +1,4 @@ -@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[] +@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[] @{ ViewData["Title"] = "Results"; } diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 11f9e7ce68..5b34e13f6c 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Admin.Services; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Migration; +using Bit.Core.Billing.Providers.Migration; #if !OSS using Bit.Commercial.Core.Utilities; diff --git a/src/Admin/Tools/Jobs/DeleteSendsJob.cs b/src/Admin/Tools/Jobs/DeleteSendsJob.cs index dafce03994..7449d2ea01 100644 --- a/src/Admin/Tools/Jobs/DeleteSendsJob.cs +++ b/src/Admin/Tools/Jobs/DeleteSendsJob.cs @@ -2,7 +2,7 @@ using Bit.Core; using Bit.Core.Jobs; using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.Services; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Quartz; namespace Bit.Admin.Tools.Jobs; @@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob } using (var scope = _serviceProvider.CreateScope()) { - var sendService = scope.ServiceProvider.GetRequiredService(); + var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService(); foreach (var send in sends) { - await sendService.DeleteSendAsync(send); + await nonAnonymousSendCommand.DeleteSendAsync(send); } } } diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 3d339bd80c..ee4a951363 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -19,8 +19,8 @@ "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } }, @@ -1107,13 +1107,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -1755,16 +1748,6 @@ "dev": true, "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1899,9 +1882,9 @@ } }, "node_modules/sass-loader": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", - "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", "dev": true, "license": "MIT", "dependencies": { @@ -1940,9 +1923,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2202,16 +2185,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2234,14 +2207,15 @@ } }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.99.8", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", + "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -2258,9 +2232,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -2361,59 +2335,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/Admin/package.json b/src/Admin/package.json index eed8eaf7aa..b69bdcace4 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -18,8 +18,8 @@ "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index b9afde2724..a8882dfaf3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -2,13 +2,11 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -137,7 +135,6 @@ public class OrganizationDomainController : Controller [AllowAnonymous] [HttpPost("domain/sso/verified")] - [RequireFeature(FeatureFlagKeys.VerifiedSsoDomainEndpoint)] public async Task GetVerifiedOrgDomainSsoDetailsAsync( [FromBody] OrganizationDomainSsoDetailsRequestModel model) { diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index f402c927e0..0d498beab1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -25,7 +25,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index 74d2feff3c..f226ba316e 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -2,7 +2,7 @@ using Bit.Api.Billing.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 1ae1f2e655..094ca0a435 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -8,6 +8,7 @@ using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -301,8 +302,12 @@ public class OrganizationBillingController( Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation); await organizationBillingService.Finalize(sale); + var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); + if (updatedOrg != null) + { + await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); + } return TypedResults.Ok(); } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 510f6c2835..bd5ab8cef4 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -109,28 +109,6 @@ public class OrganizationsController( return license; } - [HttpPost("{id:guid}/payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model) - { - if (!await currentContext.EditPaymentMethods(id)) - { - throw new NotFoundException(); - } - - await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken, - model.PaymentMethodType.Value, new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressState = model.State, - BillingAddressCity = model.City, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - TaxIdNumber = model.TaxId, - }); - } - [HttpPost("{id:guid}/upgrade")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model) diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 78e361e8b3..37130d54ce 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,10 +1,12 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -148,13 +150,33 @@ public class ProviderBillingController( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + var getProviderPriceFromStripe = featureService.IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe); + var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan => { var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); + + decimal unitAmount; + + if (getProviderPriceFromStripe) + { + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); + var price = await stripeAdapter.PriceGetAsync(priceId); + + unitAmount = price.UnitAmountDecimal.HasValue + ? price.UnitAmountDecimal.Value / 100M + : plan.PasswordManager.ProviderPortalSeatPrice; + } + else + { + unitAmount = plan.PasswordManager.ProviderPortalSeatPrice; + } + return new ConfiguredProviderPlan( providerPlan.Id, providerPlan.ProviderId, plan, + unitAmount, providerPlan.SeatMinimum ?? 0, providerPlan.PurchasedSeats ?? 0, providerPlan.AllocatedSeats ?? 0); diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index a2c6827314..e5b868af9a 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; using Stripe; @@ -35,7 +36,7 @@ public record ProviderSubscriptionResponse( .Select(providerPlan => { var plan = providerPlan.Plan; - var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; + var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * providerPlan.Price; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; return new ProviderPlanResponse( plan.Name, diff --git a/src/Api/KeyManagement/Validators/SendRotationValidator.cs b/src/Api/KeyManagement/Validators/SendRotationValidator.cs index c39f563b51..10a5d996b7 100644 --- a/src/Api/KeyManagement/Validators/SendRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/SendRotationValidator.cs @@ -12,17 +12,17 @@ namespace Bit.Api.KeyManagement.Validators; /// public class SendRotationValidator : IRotationValidator, IReadOnlyList> { - private readonly ISendService _sendService; + private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendRepository _sendRepository; /// /// Instantiates a new /// - /// Enables conversion of to + /// Enables conversion of to /// Retrieves all user s - public SendRotationValidator(ISendService sendService, ISendRepository sendRepository) + public SendRotationValidator(ISendAuthorizationService sendAuthorizationService, ISendRepository sendRepository) { - _sendService = sendService; + _sendAuthorizationService = sendAuthorizationService; _sendRepository = sendRepository; } @@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator _logger; private readonly GlobalSettings _globalSettings; private readonly ICurrentContext _currentContext; @@ -34,7 +38,9 @@ public class SendsController : Controller public SendsController( ISendRepository sendRepository, IUserService userService, - ISendService sendService, + ISendAuthorizationService sendAuthorizationService, + IAnonymousSendCommand anonymousSendCommand, + INonAnonymousSendCommand nonAnonymousSendCommand, ISendFileStorageService sendFileStorageService, ILogger logger, GlobalSettings globalSettings, @@ -42,13 +48,16 @@ public class SendsController : Controller { _sendRepository = sendRepository; _userService = userService; - _sendService = sendService; + _sendAuthorizationService = sendAuthorizationService; + _anonymousSendCommand = anonymousSendCommand; + _nonAnonymousSendCommand = nonAnonymousSendCommand; _sendFileStorageService = sendFileStorageService; _logger = logger; _globalSettings = globalSettings; _currentContext = currentContext; } + #region Anonymous endpoints [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -61,18 +70,19 @@ public class SendsController : Controller //} var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); - var (send, passwordRequired, passwordInvalid) = - await _sendService.AccessAsync(guid, model.Password); - if (passwordRequired) + var send = await _sendRepository.GetByIdAsync(guid); + SendAccessResult sendAuthResult = + await _sendAuthorizationService.AccessAsync(send, model.Password); + if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } - if (passwordInvalid) + if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (send == null) + if (sendAuthResult.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } @@ -106,19 +116,19 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } - var (url, passwordRequired, passwordInvalid) = await _sendService.GetSendFileDownloadUrlAsync(send, fileId, + var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, model.Password); - if (passwordRequired) + if (result.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } - if (passwordInvalid) + if (result.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (send == null) + if (result.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } @@ -130,6 +140,45 @@ public class SendsController : Controller }); } + [AllowAnonymous] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; + var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); + var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); + if (send == null) + { + if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) + { + await azureSendFileStorageService.DeleteBlobAsync(blobName); + } + return; + } + + await _nonAnonymousSendCommand.ConfirmFileSize(send); + } + catch (Exception e) + { + _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); + return; + } + } + } + }); + } + + #endregion + + #region Non-anonymous endpoints + [HttpGet("{id}")] public async Task Get(string id) { @@ -157,8 +206,8 @@ public class SendsController : Controller { model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var send = model.ToSend(userId, _sendService); - await _sendService.SaveSendAsync(send); + var send = model.ToSend(userId, _sendAuthorizationService); + await _nonAnonymousSendCommand.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -175,15 +224,15 @@ public class SendsController : Controller throw new BadRequestException("Invalid content. File size hint is required."); } - if (model.FileLength.Value > SendService.MAX_FILE_SIZE) + if (model.FileLength.Value > Constants.FileSize501mb) { - throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}."); + throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}."); } model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var (send, data) = model.ToSend(userId, model.File.FileName, _sendService); - var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value); + var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService); + var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value); return new SendFileUploadDataResponseModel { Url = uploadUrl, @@ -230,41 +279,7 @@ public class SendsController : Controller var send = await _sendRepository.GetByIdAsync(new Guid(id)); await Request.GetFileAsync(async (stream) => { - await _sendService.UploadFileToExistingSendAsync(stream, send); - }); - } - - [AllowAnonymous] - [HttpPost("file/validate/azure")] - public async Task AzureValidateFile() - { - return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> - { - { - "Microsoft.Storage.BlobCreated", async (eventGridEvent) => - { - try - { - var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; - var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); - var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); - if (send == null) - { - if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) - { - await azureSendFileStorageService.DeleteBlobAsync(blobName); - } - return; - } - await _sendService.ValidateSendFile(send); - } - catch (Exception e) - { - _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); - return; - } - } - } + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); }); } @@ -279,7 +294,7 @@ public class SendsController : Controller throw new NotFoundException(); } - await _sendService.SaveSendAsync(model.ToSend(send, _sendService)); + await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService)); return new SendResponseModel(send, _globalSettings); } @@ -294,7 +309,7 @@ public class SendsController : Controller } send.Password = null; - await _sendService.SaveSendAsync(send); + await _nonAnonymousSendCommand.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -308,6 +323,8 @@ public class SendsController : Controller throw new NotFoundException(); } - await _sendService.DeleteSendAsync(send); + await _nonAnonymousSendCommand.DeleteSendAsync(send); } + + #endregion } diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index 660ff41e3a..5b3fd7ba31 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -36,31 +36,31 @@ public class SendRequestModel public bool? Disabled { get; set; } public bool? HideEmail { get; set; } - public Send ToSend(Guid userId, ISendService sendService) + public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService) { var send = new Send { Type = Type, UserId = (Guid?)userId }; - ToSend(send, sendService); + ToSend(send, sendAuthorizationService); return send; } - public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendService sendService) + public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService) { var send = ToSendBase(new Send { Type = Type, UserId = (Guid?)userId - }, sendService); + }, sendAuthorizationService); var data = new SendFileData(Name, Notes, fileName); return (send, data); } - public Send ToSend(Send existingSend, ISendService sendService) + public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) { - existingSend = ToSendBase(existingSend, sendService); + existingSend = ToSendBase(existingSend, sendAuthorizationService); switch (existingSend.Type) { case SendType.File: @@ -125,7 +125,7 @@ public class SendRequestModel } } - private Send ToSendBase(Send existingSend, ISendService sendService) + private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizationService) { existingSend.Key = Key; existingSend.ExpirationDate = ExpirationDate; @@ -133,7 +133,7 @@ public class SendRequestModel existingSend.MaxAccessCount = MaxAccessCount; if (!string.IsNullOrWhiteSpace(Password)) { - existingSend.Password = sendService.HashPassword(Password); + existingSend.Password = authorizationService.HashPassword(Password); } existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault(); diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 4e35a6c894..1f6ef741df 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -1,8 +1,8 @@ using Bit.Billing.Constants; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Stripe; diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 4c27098f38..e31d1dceb7 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,11 +1,11 @@ using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; -using Bit.Core.Billing.Tax.Services; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -25,8 +25,7 @@ public class UpcomingInvoiceHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, - IValidateSponsorshipCommand validateSponsorshipCommand, - IAutomaticTaxFactory automaticTaxFactory) + IValidateSponsorshipCommand validateSponsorshipCommand) : IUpcomingInvoiceHandler { public async Task HandleAsync(Event parsedEvent) @@ -46,6 +45,8 @@ public class UpcomingInvoiceHandler( var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + if (organizationId.HasValue) { var organization = await organizationRepository.GetByIdAsync(organizationId.Value); @@ -55,7 +56,7 @@ public class UpcomingInvoiceHandler( return; } - await TryEnableAutomaticTaxAsync(subscription); + await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -100,7 +101,25 @@ public class UpcomingInvoiceHandler( return; } - await TryEnableAutomaticTaxAsync(subscription); + if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation()) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", + user.Id, + parsedEvent.Id); + } + } if (user.Premium) { @@ -116,7 +135,7 @@ public class UpcomingInvoiceHandler( return; } - await TryEnableAutomaticTaxAsync(subscription); + await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge); await SendUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice); } @@ -139,50 +158,123 @@ public class UpcomingInvoiceHandler( } } - private async Task TryEnableAutomaticTaxAsync(Subscription subscription) + private async Task AlignOrganizationTaxConcernsAsync( + Organization organization, + Subscription subscription, + string eventId, + bool setNonUSBusinessUseToReverseCharge) { - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id)); - var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); - var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription); + var nonUSBusinessUse = + organization.PlanType.GetProductTier() != ProductTierType.Families && + subscription.Customer.Address.Country != "US"; - if (updateOptions == null) + bool setAutomaticTaxToEnabled; + + if (setNonUSBusinessUseToReverseCharge) + { + if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { - return; + try + { + await stripeFacade.UpdateCustomer(subscription.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}", + organization.Id, + eventId); + } } - await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); - return; + setAutomaticTaxToEnabled = true; } - - if (subscription.AutomaticTax.Enabled || - !subscription.Customer.HasBillingLocation() || - await IsNonTaxableNonUSBusinessUseSubscription(subscription)) + else { - return; + setAutomaticTaxToEnabled = + subscription.Customer.HasRecognizedTaxLocation() && + (subscription.Customer.Address.Country == "US" || + (nonUSBusinessUse && subscription.Customer.TaxIds.Any())); } - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions + if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled) + { + try { - DefaultTaxRates = [], - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}", + organization.Id, + eventId); + } + } + } - return; + private async Task AlignProviderTaxConcernsAsync( + Provider provider, + Subscription subscription, + string eventId, + bool setNonUSBusinessUseToReverseCharge) + { + bool setAutomaticTaxToEnabled; - async Task IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) + if (setNonUSBusinessUseToReverseCharge) { - var familyPriceIds = (await Task.WhenAll( - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) - .Select(plan => plan.PasswordManager.StripePlanId); + if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + { + try + { + await stripeFacade.UpdateCustomer(subscription.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}", + provider.Id, + eventId); + } + } - return localSubscription.Customer.Address.Country != "US" && - localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && - !localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() && - !localSubscription.Customer.TaxIds.Any(); + setAutomaticTaxToEnabled = true; + } + else + { + setAutomaticTaxToEnabled = + subscription.Customer.HasRecognizedTaxLocation() && + (subscription.Customer.Address.Country == "US" || + subscription.Customer.TaxIds.Any()); + } + + if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}", + provider.Id, + eventId); + } } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..b8802ffd0c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -0,0 +1,187 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public record ProviderClientOrganizationSignUpResponse( + Organization Organization, + Collection DefaultCollection); + +public interface IProviderClientOrganizationSignUpCommand +{ + /// + /// Sign up a new client organization for a provider. + /// + /// The signup information. + /// A tuple containing the new organization and its default collection. + Task SignUpClientOrganizationAsync(OrganizationSignup signup); +} + +public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizationSignUpCommand +{ + public const string PlanNullErrorMessage = "Password Manager Plan was null."; + public const string PlanDisabledErrorMessage = "Password Manager Plan is disabled."; + public const string AdditionalSeatsNegativeErrorMessage = "You can't subtract Password Manager seats!"; + + private readonly ICurrentContext _currentContext; + private readonly IPricingClient _pricingClient; + private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; + + public ProviderClientOrganizationSignUpCommand( + ICurrentContext currentContext, + IPricingClient pricingClient, + IReferenceEventService referenceEventService, + IOrganizationRepository organizationRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository) + { + _currentContext = currentContext; + _pricingClient = pricingClient; + _referenceEventService = referenceEventService; + _organizationRepository = organizationRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; + } + + public async Task SignUpClientOrganizationAsync(OrganizationSignup signup) + { + var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); + + ValidatePlan(plan, signup.AdditionalSeats); + + var organization = new Organization + { + // Pre-generate the org id so that we can save it with the Stripe subscription. + Id = CoreHelpers.GenerateComb(), + Name = signup.Name, + BillingEmail = signup.BillingEmail, + PlanType = plan!.Type, + Seats = signup.AdditionalSeats, + MaxCollections = plan.PasswordManager.MaxCollections, + MaxStorageGb = 1, + UsePolicies = plan.HasPolicies, + UseSso = plan.HasSso, + UseOrganizationDomains = plan.HasOrganizationDomains, + UseGroups = plan.HasGroups, + UseEvents = plan.HasEvents, + UseDirectory = plan.HasDirectory, + UseTotp = plan.HasTotp, + Use2fa = plan.Has2fa, + UseApi = plan.HasApi, + UseResetPassword = plan.HasResetPassword, + SelfHost = plan.HasSelfHost, + UsersGetPremium = plan.UsersGetPremium, + UseCustomPermissions = plan.HasCustomPermissions, + UseScim = plan.HasScim, + Plan = plan.Name, + Gateway = GatewayType.Stripe, + ReferenceData = signup.Owner.ReferenceData, + Enabled = true, + LicenseKey = CoreHelpers.SecureRandomString(20), + PublicKey = signup.PublicKey, + PrivateKey = signup.PrivateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + // Secrets Manager not available for purchase with Consolidated Billing. + UseSecretsManager = false, + }; + + var returnValue = await SignUpAsync(organization, signup.CollectionName); + + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) + { + PlanName = plan.Name, + PlanType = plan.Type, + Seats = returnValue.Organization.Seats, + SignupInitiationPath = signup.InitiationPath, + Storage = returnValue.Organization.MaxStorageGb, + }); + + return returnValue; + } + + private static void ValidatePlan(Plan plan, int additionalSeats) + { + if (plan is null) + { + throw new BadRequestException(PlanNullErrorMessage); + } + + if (plan.Disabled) + { + throw new BadRequestException(PlanDisabledErrorMessage); + } + + if (additionalSeats < 0) + { + throw new BadRequestException(AdditionalSeatsNegativeErrorMessage); + } + } + + /// + /// Private helper method to create a new organization. + /// + private async Task SignUpAsync( + Organization organization, string collectionName) + { + try + { + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + Collection defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + await _collectionRepository.CreateAsync(defaultCollection, null, null); + } + + return new ProviderClientOrganizationSignUpResponse(organization, defaultCollection); + } + catch + { + if (organization.Id != default) + { + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 1e53be734e..5fe68bd22e 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -11,8 +11,6 @@ namespace Bit.Core.Services; public interface IOrganizationService { - Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, PaymentMethodType paymentMethodType, - TaxInfo taxInfo); Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null); Task ReinstateSubscriptionAsync(Guid organizationId); Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); @@ -20,9 +18,6 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); -#nullable enable - Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup); -#nullable disable /// /// Create a new organization on a self-hosted instance /// diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1ced923b45..26ff421328 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -144,27 +144,6 @@ public class OrganizationService : IOrganizationService _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; } - public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, - PaymentMethodType paymentMethodType, TaxInfo taxInfo) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - await _paymentService.SaveTaxInfoAsync(organization, taxInfo); - var updated = await _paymentService.UpdatePaymentMethodAsync( - organization, - paymentMethodType, - paymentToken, - taxInfo); - if (updated) - { - await ReplaceAndUpdateCacheAsync(organization); - } - } - public async Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null) { var organization = await GetOrgById(organizationId); @@ -431,66 +410,6 @@ public class OrganizationService : IOrganizationService } } - public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup) - { - var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); - - ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); - - var organization = new Organization - { - // Pre-generate the org id so that we can save it with the Stripe subscription. - Id = CoreHelpers.GenerateComb(), - Name = signup.Name, - BillingEmail = signup.BillingEmail, - PlanType = plan!.Type, - Seats = signup.AdditionalSeats, - MaxCollections = plan.PasswordManager.MaxCollections, - MaxStorageGb = 1, - UsePolicies = plan.HasPolicies, - UseSso = plan.HasSso, - UseOrganizationDomains = plan.HasOrganizationDomains, - UseGroups = plan.HasGroups, - UseEvents = plan.HasEvents, - UseDirectory = plan.HasDirectory, - UseTotp = plan.HasTotp, - Use2fa = plan.Has2fa, - UseApi = plan.HasApi, - UseResetPassword = plan.HasResetPassword, - SelfHost = plan.HasSelfHost, - UsersGetPremium = plan.UsersGetPremium, - UseCustomPermissions = plan.HasCustomPermissions, - UseScim = plan.HasScim, - Plan = plan.Name, - Gateway = GatewayType.Stripe, - ReferenceData = signup.Owner.ReferenceData, - Enabled = true, - LicenseKey = CoreHelpers.SecureRandomString(20), - PublicKey = signup.PublicKey, - PrivateKey = signup.PrivateKey, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created, - UsePasswordManager = true, - // Secrets Manager not available for purchase with Consolidated Billing. - UseSecretsManager = false, - }; - - var returnValue = await SignUpAsync(organization, default, signup.OwnerKey, signup.CollectionName, false); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - }); - - return returnValue; - } - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index c3e3ec6c30..28f4dea4b2 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -2,10 +2,6 @@ public static class StripeConstants { - public static class Prices - { - public const string StoragePlanPersonal = "personal-storage-gb-annually"; - } public static class AutomaticTaxStatus { public const string Failed = "failed"; @@ -69,6 +65,11 @@ public static class StripeConstants public const string USBankAccount = "us_bank_account"; } + public static class Prices + { + public const string StoragePlanPersonal = "personal-storage-gb-annually"; + } + public static class ProrationBehavior { public const string AlwaysInvoice = "always_invoice"; @@ -88,6 +89,13 @@ public static class StripeConstants public const string Paused = "paused"; } + public static class TaxExempt + { + public const string Exempt = "exempt"; + public const string None = "none"; + public const string Reverse = "reverse"; + } + public static class ValidateTaxLocationTiming { public const string Deferred = "deferred"; diff --git a/src/Core/Billing/Extensions/CustomerExtensions.cs b/src/Core/Billing/Extensions/CustomerExtensions.cs index 3e0c1ea0fb..aa22331f7c 100644 --- a/src/Core/Billing/Extensions/CustomerExtensions.cs +++ b/src/Core/Billing/Extensions/CustomerExtensions.cs @@ -15,12 +15,7 @@ public static class CustomerExtensions } }; - /// - /// Determines if a Stripe customer supports automatic tax - /// - /// - /// - public static bool HasTaxLocationVerified(this Customer customer) => + public static bool HasRecognizedTaxLocation(this Customer customer) => customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation; public static decimal GetBillingBalance(this Customer customer) diff --git a/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs b/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs index d70af78fa8..22a715733b 100644 --- a/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs +++ b/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs @@ -22,7 +22,7 @@ public static class SubscriptionUpdateOptionsExtensions } // We might only need to check the automatic tax status. - if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country)) + if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country)) { return false; } diff --git a/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs b/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs index 88df5638c9..d00b5b46a4 100644 --- a/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs +++ b/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs @@ -22,7 +22,7 @@ public static class UpcomingInvoiceOptionsExtensions } // We might only need to check the automatic tax status. - if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country)) + if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country)) { return false; } diff --git a/src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs b/src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs similarity index 88% rename from src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs rename to src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs index 1e719b3ceb..bbb0a90b04 100644 --- a/src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs +++ b/src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs @@ -1,12 +1,12 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ClientOrganizationMigrationRecord : ITableObject { diff --git a/src/Core/Billing/Entities/ProviderInvoiceItem.cs b/src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs similarity index 87% rename from src/Core/Billing/Entities/ProviderInvoiceItem.cs rename to src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs index 566d7514e7..9d9eeda754 100644 --- a/src/Core/Billing/Entities/ProviderInvoiceItem.cs +++ b/src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ProviderInvoiceItem : ITableObject { diff --git a/src/Core/Billing/Entities/ProviderPlan.cs b/src/Core/Billing/Providers/Entities/ProviderPlan.cs similarity index 86% rename from src/Core/Billing/Entities/ProviderPlan.cs rename to src/Core/Billing/Providers/Entities/ProviderPlan.cs index fd131f64e6..d06c81e9ce 100644 --- a/src/Core/Billing/Entities/ProviderPlan.cs +++ b/src/Core/Billing/Providers/Entities/ProviderPlan.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Enums; +#nullable enable + +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ProviderPlan : ITableObject { diff --git a/src/Core/Billing/Migration/Models/ClientMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs similarity index 90% rename from src/Core/Billing/Migration/Models/ClientMigrationTracker.cs rename to src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs index 69398004fd..ae0c28de86 100644 --- a/src/Core/Billing/Migration/Models/ClientMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ClientMigrationProgress { diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationResult.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs similarity index 93% rename from src/Core/Billing/Migration/Models/ProviderMigrationResult.cs rename to src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs index 137ba8bd0d..6f3c3be11d 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationResult.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public class ProviderMigrationResult { diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs similarity index 90% rename from src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs rename to src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs index 7bfef8a931..f4708d4cbd 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ProviderMigrationProgress { diff --git a/src/Core/Billing/Migration/ServiceCollectionExtensions.cs b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs similarity index 71% rename from src/Core/Billing/Migration/ServiceCollectionExtensions.cs rename to src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs index 109259d59a..1061c82888 100644 --- a/src/Core/Billing/Migration/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ -using Bit.Core.Billing.Migration.Services; -using Bit.Core.Billing.Migration.Services.Implementations; +using Bit.Core.Billing.Providers.Migration.Services; +using Bit.Core.Billing.Providers.Migration.Services.Implementations; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Billing.Migration; +namespace Bit.Core.Billing.Providers.Migration; public static class ServiceCollectionExtensions { diff --git a/src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs similarity index 85% rename from src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs rename to src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs index 6734c69566..70649590df 100644 --- a/src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs @@ -1,8 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IMigrationTrackerCache { diff --git a/src/Core/Billing/Migration/Services/IOrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs similarity index 72% rename from src/Core/Billing/Migration/Services/IOrganizationMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs index 7bc9443717..a0548277b4 100644 --- a/src/Core/Billing/Migration/Services/IOrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs @@ -1,6 +1,6 @@ using Bit.Core.AdminConsole.Entities; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IOrganizationMigrator { diff --git a/src/Core/Billing/Migration/Services/IProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs similarity index 55% rename from src/Core/Billing/Migration/Services/IProviderMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs index 9ca14e7fd9..328c2419f4 100644 --- a/src/Core/Billing/Migration/Services/IProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IProviderMigrator { diff --git a/src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs similarity index 96% rename from src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs index 920bc55392..ea7d118cfa 100644 --- a/src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs @@ -1,11 +1,11 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class MigrationTrackerDistributedCache( [FromKeyedServices("persistent")] diff --git a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs similarity index 98% rename from src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs index 204022380d..3b874579e5 100644 --- a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs @@ -1,10 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; using Stripe; using Plan = Bit.Core.Models.StaticStore.Plan; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class OrganizationMigrator( IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, diff --git a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs similarity index 98% rename from src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 384cfca1d1..3a0b579dcf 100644 --- a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -3,18 +3,18 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Migration.Models; -using Bit.Core.Billing.Repositories; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class ProviderMigrator( IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, diff --git a/src/Core/Billing/Models/AddableOrganization.cs b/src/Core/Billing/Providers/Models/AddableOrganization.cs similarity index 72% rename from src/Core/Billing/Models/AddableOrganization.cs rename to src/Core/Billing/Providers/Models/AddableOrganization.cs index fe6d5458bd..aca7a158b0 100644 --- a/src/Core/Billing/Models/AddableOrganization.cs +++ b/src/Core/Billing/Providers/Models/AddableOrganization.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Providers.Models; public record AddableOrganization( Guid Id, diff --git a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs b/src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs similarity index 80% rename from src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs rename to src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs index 385782c8ad..053d912291 100644 --- a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs +++ b/src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Providers.Models; public record ChangeProviderPlanCommand( Provider Provider, diff --git a/src/Core/Billing/Models/ConfiguredProviderPlan.cs b/src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs similarity index 75% rename from src/Core/Billing/Models/ConfiguredProviderPlan.cs rename to src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs index 72c1ec5b07..d875106a9e 100644 --- a/src/Core/Billing/Models/ConfiguredProviderPlan.cs +++ b/src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs @@ -1,11 +1,12 @@ using Bit.Core.Models.StaticStore; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Providers.Models; public record ConfiguredProviderPlan( Guid Id, Guid ProviderId, Plan Plan, + decimal Price, int SeatMinimum, int PurchasedSeats, int AssignedSeats); diff --git a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs b/src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs similarity index 89% rename from src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs rename to src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs index 2d2535b60a..dfd04e6605 100644 --- a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs +++ b/src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Providers.Models; /// The provider to update the seat minimums for. /// The new seat minimums for the provider. diff --git a/src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs b/src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs similarity index 77% rename from src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs rename to src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs index 2165984383..53eb51403f 100644 --- a/src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IClientOrganizationMigrationRecordRepository : IRepository { diff --git a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs b/src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs similarity index 74% rename from src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs rename to src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs index a722d4cf9d..931d8a9186 100644 --- a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IProviderInvoiceItemRepository : IRepository { diff --git a/src/Core/Billing/Repositories/IProviderPlanRepository.cs b/src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs similarity index 64% rename from src/Core/Billing/Repositories/IProviderPlanRepository.cs rename to src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs index eccbad82bb..d1cf91ea56 100644 --- a/src/Core/Billing/Repositories/IProviderPlanRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IProviderPlanRepository : IRepository { diff --git a/src/Core/Billing/Services/IBusinessUnitConverter.cs b/src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs similarity index 98% rename from src/Core/Billing/Services/IBusinessUnitConverter.cs rename to src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs index 06ff883eae..99df6b1bef 100644 --- a/src/Core/Billing/Services/IBusinessUnitConverter.cs +++ b/src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using OneOf; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Providers.Services; public interface IBusinessUnitConverter { diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs similarity index 98% rename from src/Core/Billing/Services/IProviderBillingService.cs rename to src/Core/Billing/Providers/Services/IProviderBillingService.cs index b6ddbdd642..b634f1a81c 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -1,14 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; using Stripe; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Providers.Services; public interface IProviderBillingService { diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 20f6105c2a..95df34dfd4 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; @@ -35,16 +35,15 @@ public class OrganizationBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - ITaxService taxService, - IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService + ITaxService taxService) : IOrganizationBillingService { public async Task Finalize(OrganizationSale sale) { var (organization, customerSetup, subscriptionSetup) = sale; var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null - ? await CreateCustomerAsync(organization, customerSetup) - : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); + ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) + : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); @@ -121,7 +120,8 @@ public class OrganizationBillingService( subscription.CurrentPeriodEnd); } - public async Task UpdatePaymentMethod( + public async Task + UpdatePaymentMethod( Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation) @@ -151,8 +151,11 @@ public class OrganizationBillingService( private async Task CreateCustomerAsync( Organization organization, - CustomerSetup customerSetup) + CustomerSetup customerSetup, + PlanType? updatedPlanType = null) { + var planType = updatedPlanType ?? organization.PlanType; + var displayName = organization.DisplayName(); var customerCreateOptions = new CustomerCreateOptions @@ -212,13 +215,24 @@ public class OrganizationBillingService( City = customerSetup.TaxInformation.City, PostalCode = customerSetup.TaxInformation.PostalCode, State = customerSetup.TaxInformation.State, - Country = customerSetup.TaxInformation.Country, + Country = customerSetup.TaxInformation.Country }; + customerCreateOptions.Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }; + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && + planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families && + customerSetup.TaxInformation.Country != "US") + { + customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + } + if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) { var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, @@ -399,21 +413,68 @@ public class OrganizationBillingService( TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays }; - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriptionSetup.PlanType); - var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); - automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - else + else if (customer.HasRecognizedTaxLocation()) { - subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions(); - subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation(); + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = + subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families || + customer.Address.Country == "US" || + customer.TaxIds.Any() + }; } return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } + private async Task GetCustomerWhileEnsuringCorrectTaxExemptionAsync( + Organization organization, + SubscriptionSetup subscriptionSetup) + { + var customer = await subscriberService.GetCustomerOrThrow(organization, + new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); + + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (!setNonUSBusinessUseToReverseCharge || subscriptionSetup.PlanType.GetProductTier() is + not (ProductTierType.Teams or + ProductTierType.TeamsStarter or + ProductTierType.Enterprise)) + { + return customer; + } + + List expansions = ["tax", "tax_ids"]; + + customer = customer switch + { + { Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await + stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + Expand = expansions, + TaxExempt = StripeConstants.TaxExempt.Reverse + }), + { Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await + stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + Expand = expansions, + TaxExempt = StripeConstants.TaxExempt.None + }), + _ => customer + }; + + return customer; + } + private async Task IsEligibleForSelfHostAsync( Organization organization) { diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 1b845e93f1..7496157aaa 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -3,8 +3,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -12,7 +10,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Braintree; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; using Customer = Stripe.Customer; @@ -24,20 +21,18 @@ using static Utilities; public class PremiumUserBillingService( IBraintreeGateway braintreeGateway, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - IUserRepository userRepository, - [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService + IUserRepository userRepository) : IPremiumUserBillingService { public async Task Credit(User user, decimal amount) { var customer = await subscriberService.GetCustomer(user); - // Negative credit represents a balance and all Stripe denomination is in cents. + // Negative credit represents a balance, and all Stripe denomination is in cents. var credit = (long)(amount * -100); if (customer == null) @@ -184,7 +179,7 @@ public class PremiumUserBillingService( City = customerSetup.TaxInformation.City, PostalCode = customerSetup.TaxInformation.PostalCode, State = customerSetup.TaxInformation.State, - Country = customerSetup.TaxInformation.Country, + Country = customerSetup.TaxInformation.Country }, Description = user.Name, Email = user.Email, @@ -324,6 +319,10 @@ public class PremiumUserBillingService( var subscriptionCreateOptions = new SubscriptionCreateOptions { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, Items = subscriptionItemOptionsList, @@ -337,18 +336,6 @@ public class PremiumUserBillingService( OffSession = true }; - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); - } - else - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported, - }; - } - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); if (usingPayPal) @@ -380,7 +367,7 @@ public class PremiumUserBillingService( City = taxInformation.City, PostalCode = taxInformation.PostalCode, State = taxInformation.State, - Country = taxInformation.Country, + Country = taxInformation.Country }, Expand = ["tax"], Tax = new CustomerTaxOptions diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 10247cdf92..75a1bf76ec 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -1,7 +1,10 @@ -using Bit.Core.Billing.Caches; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; @@ -28,8 +31,7 @@ public class SubscriberService( ILogger logger, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ITaxService taxService, - IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService + ITaxService taxService) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -128,7 +130,7 @@ public class SubscriberService( [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion }, Email = subscriber.BillingEmailAddress(), - PaymentMethodNonce = paymentMethodNonce, + PaymentMethodNonce = paymentMethodNonce }); if (customerResult.IsSuccess()) @@ -482,7 +484,7 @@ public class SubscriberService( var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); - // Find the customer's existing setup intents that should be cancelled. + // Find the customer's existing setup intents that should be canceled. var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) .Where(si => si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); @@ -519,7 +521,7 @@ public class SubscriberService( await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - // Find the customer's existing setup intents that should be cancelled. + // Find the customer's existing setup intents that should be canceled. var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) .Where(si => si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); @@ -637,7 +639,8 @@ public class SubscriberService( logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInformation.Country, taxInformation.TaxId); - throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); + + throw new BadRequestException("billingTaxIdTypeInferenceError"); } } @@ -654,53 +657,84 @@ public class SubscriberService( logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", taxInformation.TaxId, taxInformation.Country); - throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); + + throw new 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"); + + throw new BadRequestException("billingTaxIdCreationError"); } } } - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var subscription = + customer.Subscriptions.First(subscription => subscription.Id == subscriber.GatewaySubscriptionId); + + var isBusinessUseSubscriber = subscriber switch { - if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + Organization organization => organization.PlanType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families, + Provider => true, + _ => false + }; + + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && isBusinessUseSubscriber) + { + switch (customer) { - var subscriptionGetOptions = new SubscriptionGetOptions + case { - Expand = ["customer.tax", "customer.tax_ids"] - }; - var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id)); - var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); - var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription); - if (automaticTaxOptions?.AutomaticTax?.Enabled != null) + Address.Country: not "US", + TaxExempt: not StripeConstants.TaxExempt.Reverse + }: + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + break; + case { - await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, automaticTaxOptions); - } + Address.Country: "US", + TaxExempt: StripeConstants.TaxExempt.Reverse + }: + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.None }); + break; } - } - else - { - if (SubscriberIsEligibleForAutomaticTax(subscriber, customer)) + + if (!subscription.AutomaticTax.Enabled) { - await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }); } + } + else + { + var automaticTaxShouldBeEnabled = subscriber switch + { + User => true, + Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families || + customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + _ => false + }; - return; - - bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer) - => !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) && - (localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) && - localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported; + if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled) + { + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } } } diff --git a/src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs b/src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs similarity index 93% rename from src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs rename to src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs index 19a4f0bdfa..a58daa9c48 100644 --- a/src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs +++ b/src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Tax.Models; public class AutomaticTaxFactoryParameters { diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs index 90a3bc08ad..c0a31efb3c 100644 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Tax.Services; diff --git a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs index fa110f79d5..6086a16b79 100644 --- a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs @@ -1,7 +1,7 @@ #nullable enable using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Services; diff --git a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs index 310aced130..6affc57354 100644 --- a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs @@ -76,7 +76,7 @@ public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : I private bool ShouldBeEnabled(Customer customer) { - if (!customer.HasTaxLocationVerified()) + if (!customer.HasRecognizedTaxLocation()) { return false; } diff --git a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs index e89fc6a3b3..615222259e 100644 --- a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs @@ -59,6 +59,6 @@ public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : I private static bool ShouldBeEnabled(Customer customer) { - return customer.HasTaxLocationVerified(); + return customer.HasRecognizedTaxLocation(); } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 707001ddcc..1c31ffaab4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -143,13 +143,14 @@ public static class FeatureFlagKeys public const string UsePricingService = "use-pricing-service"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; - public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; + public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge"; + public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe"; /* Data Insights and Reporting Team */ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b016e329bf..2bc05017d5 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -69,8 +69,11 @@ public static class OrganizationServiceCollectionExtensions services.AddBaseOrganizationSubscriptionCommandsQueries(); } - private static IServiceCollection AddOrganizationSignUpCommands(this IServiceCollection services) => + private static void AddOrganizationSignUpCommands(this IServiceCollection services) + { services.AddScoped(); + services.AddScoped(); + } private static void AddOrganizationDeleteCommands(this IServiceCollection services) { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 3fdb829cf4..af96b88ee6 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; using Bit.Core.Entities; -using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; @@ -30,8 +29,6 @@ public interface IPaymentService Task AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts); Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false); Task ReinstateSubscriptionAsync(ISubscriber subscriber); - Task UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, - string paymentToken, TaxInfo taxInfo = null); Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount); Task GetBillingAsync(ISubscriber subscriber); Task GetBillingHistoryAsync(ISubscriber subscriber); diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index cb95732a6e..1ba93da4fa 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -57,4 +57,5 @@ public interface IStripeAdapter Task SetupIntentGet(string id, SetupIntentGetOptions options = null); Task SetupIntentVerifyMicroDeposit(string id, SetupIntentVerifyMicrodepositsOptions options); Task> TestClockListAsync(); + Task PriceGetAsync(string id, PriceGetOptions options = null); } diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index f7f4fea066..fd9f212ee7 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -283,4 +283,7 @@ public class StripeAdapter : IStripeAdapter } return items; } + + public Task PriceGetAsync(string id, PriceGetOptions options = null) + => _priceService.GetAsync(id, options); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 65c0525535..23d06bed2b 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,12 +1,12 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; @@ -38,7 +38,6 @@ public class StripePaymentService : IPaymentService private readonly IGlobalSettings _globalSettings; private readonly IFeatureService _featureService; private readonly ITaxService _taxService; - private readonly ISubscriberService _subscriberService; private readonly IPricingClient _pricingClient; private readonly IAutomaticTaxFactory _automaticTaxFactory; private readonly IAutomaticTaxStrategy _personalUseTaxStrategy; @@ -51,7 +50,6 @@ public class StripePaymentService : IPaymentService IGlobalSettings globalSettings, IFeatureService featureService, ITaxService taxService, - ISubscriberService subscriberService, IPricingClient pricingClient, IAutomaticTaxFactory automaticTaxFactory, [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy) @@ -63,7 +61,6 @@ public class StripePaymentService : IPaymentService _globalSettings = globalSettings; _featureService = featureService; _taxService = taxService; - _subscriberService = subscriberService; _pricingClient = pricingClient; _automaticTaxFactory = automaticTaxFactory; _personalUseTaxStrategy = personalUseTaxStrategy; @@ -136,15 +133,68 @@ public class StripePaymentService : IPaymentService if (subscriptionUpdate is CompleteSubscriptionUpdate) { - if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var setNonUSBusinessUseToReverseCharge = + _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, updatedItemOptions.Select(x => x.Plan ?? x.Price)); - var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters); - automaticTaxStrategy.SetUpdateOptions(subUpdateOptions, sub); + if (sub.Customer is + { + Address.Country: not "US", + TaxExempt: not StripeConstants.TaxExempt.Reverse + }) + { + await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - else + else if (sub.Customer.HasRecognizedTaxLocation()) { - subUpdateOptions.EnableAutomaticTax(sub.Customer, sub); + switch (subscriber) + { + case User: + { + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + break; + } + case Organization: + { + if (sub.Customer.Address.Country == "US") + { + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + } + else + { + var familyPriceIds = (await Task.WhenAll( + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) + .Select(plan => plan.PasswordManager.StripePlanId); + + var updateIsForPersonalUse = updatedItemOptions + .Select(option => option.Price) + .Intersect(familyPriceIds) + .Any(); + + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = updateIsForPersonalUse || sub.Customer.TaxIds.Any() + }; + } + + break; + } + case Provider: + { + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = sub.Customer.Address.Country == "US" || + sub.Customer.TaxIds.Any() + }; + break; + } + } } } @@ -202,7 +252,7 @@ public class StripePaymentService : IPaymentService } else if (!invoice.Paid) { - // Pay invoice with no charge to customer this completes the invoice immediately without waiting the scheduled 1h + // Pay invoice with no charge to the customer this completes the invoice immediately without waiting the scheduled 1h invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); paymentIntentClientSecret = null; } @@ -585,309 +635,6 @@ public class StripePaymentService : IPaymentService } } - public async Task UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, - string paymentToken, TaxInfo taxInfo = null) - { - if (subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if (subscriber.Gateway.HasValue && subscriber.Gateway.Value != GatewayType.Stripe) - { - throw new GatewayException("Switching from one payment type to another is not supported. " + - "Contact us for assistance."); - } - - var createdCustomer = false; - Braintree.Customer braintreeCustomer = null; - string stipeCustomerSourceToken = null; - string stipeCustomerPaymentMethodId = null; - var stripeCustomerMetadata = new Dictionary - { - { "region", _globalSettings.BaseServiceUri.CloudRegion } - }; - var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount; - - Customer customer = null; - - if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - var options = new CustomerGetOptions { Expand = ["sources", "tax", "subscriptions"] }; - customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, options); - if (customer.Metadata?.Any() ?? false) - { - stripeCustomerMetadata = customer.Metadata; - } - } - - var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId"); - if (stripePaymentMethod) - { - if (paymentToken.StartsWith("pm_")) - { - stipeCustomerPaymentMethodId = paymentToken; - } - else - { - stipeCustomerSourceToken = paymentToken; - } - } - else if (paymentMethodType == PaymentMethodType.PayPal) - { - if (hadBtCustomer) - { - var pmResult = await _btGateway.PaymentMethod.CreateAsync(new Braintree.PaymentMethodRequest - { - CustomerId = stripeCustomerMetadata["btCustomerId"], - PaymentMethodNonce = paymentToken - }); - - if (pmResult.IsSuccess()) - { - var customerResult = await _btGateway.Customer.UpdateAsync( - stripeCustomerMetadata["btCustomerId"], new Braintree.CustomerRequest - { - DefaultPaymentMethodToken = pmResult.Target.Token - }); - - if (customerResult.IsSuccess() && customerResult.Target.PaymentMethods.Length > 0) - { - braintreeCustomer = customerResult.Target; - } - else - { - await _btGateway.PaymentMethod.DeleteAsync(pmResult.Target.Token); - hadBtCustomer = false; - } - } - else - { - hadBtCustomer = false; - } - } - - if (!hadBtCustomer) - { - var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest - { - PaymentMethodNonce = paymentToken, - Email = subscriber.BillingEmailAddress(), - Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() + - Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false), - CustomFields = new Dictionary - { - [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), - [subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion - } - }); - - if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) - { - throw new GatewayException("Failed to create PayPal customer record."); - } - - braintreeCustomer = customerResult.Target; - } - } - else - { - throw new GatewayException("Payment method is not supported at this time."); - } - - if (stripeCustomerMetadata.ContainsKey("btCustomerId")) - { - if (braintreeCustomer?.Id != stripeCustomerMetadata["btCustomerId"]) - { - stripeCustomerMetadata["btCustomerId_old"] = stripeCustomerMetadata["btCustomerId"]; - } - - stripeCustomerMetadata["btCustomerId"] = braintreeCustomer?.Id; - } - else if (!string.IsNullOrWhiteSpace(braintreeCustomer?.Id)) - { - stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); - } - - 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 - { - Description = subscriber.BillingName(), - Email = subscriber.BillingEmailAddress(), - Metadata = stripeCustomerMetadata, - Source = stipeCustomerSourceToken, - PaymentMethod = stipeCustomerPaymentMethodId, - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = stipeCustomerPaymentMethodId, - CustomFields = - [ - new CustomerInvoiceSettingsCustomFieldOptions() - { - Name = subscriber.SubscriberType(), - Value = subscriber.GetFormattedInvoiceName() - } - - ] - }, - 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 - }, - TaxIdData = string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) - ? [] - : [ - new CustomerTaxIdDataOptions - { - Type = taxInfo.TaxIdType, - Value = taxInfo.TaxIdNumber - } - ], - Expand = ["sources", "tax", "subscriptions"], - }); - - subscriber.Gateway = GatewayType.Stripe; - subscriber.GatewayCustomerId = customer.Id; - createdCustomer = true; - } - - if (!createdCustomer) - { - string defaultSourceId = null; - string defaultPaymentMethodId = null; - if (stripePaymentMethod) - { - if (!string.IsNullOrWhiteSpace(stipeCustomerSourceToken) && paymentToken.StartsWith("btok_")) - { - var bankAccount = await _stripeAdapter.BankAccountCreateAsync(customer.Id, new BankAccountCreateOptions - { - Source = paymentToken - }); - defaultSourceId = bankAccount.Id; - } - else if (!string.IsNullOrWhiteSpace(stipeCustomerPaymentMethodId)) - { - await _stripeAdapter.PaymentMethodAttachAsync(stipeCustomerPaymentMethodId, - new PaymentMethodAttachOptions { Customer = customer.Id }); - defaultPaymentMethodId = stipeCustomerPaymentMethodId; - } - } - - if (customer.Sources != null) - { - foreach (var source in customer.Sources.Where(s => s.Id != defaultSourceId)) - { - if (source is BankAccount) - { - await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); - } - else if (source is Card) - { - await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id); - } - } - } - - var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(new PaymentMethodListOptions - { - Customer = customer.Id, - Type = "card" - }); - foreach (var cardMethod in cardPaymentMethods.Where(m => m.Id != defaultPaymentMethodId)) - { - await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new PaymentMethodDetachOptions()); - } - - await _subscriberService.UpdateTaxInformation(subscriber, TaxInformation.From(taxInfo)); - - customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions - { - Metadata = stripeCustomerMetadata, - DefaultSource = defaultSourceId, - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = defaultPaymentMethodId, - CustomFields = - [ - new CustomerInvoiceSettingsCustomFieldOptions() - { - Name = subscriber.SubscriberType(), - Value = subscriber.GetFormattedInvoiceName() - } - ] - }, - Expand = ["tax", "subscriptions"] - }); - } - - if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) - { - var subscriptionGetOptions = new SubscriptionGetOptions - { - Expand = ["customer.tax", "customer.tax_ids"] - }; - var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); - - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id)); - var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters); - var subscriptionUpdateOptions = automaticTaxStrategy.GetUpdateOptions(subscription); - - if (subscriptionUpdateOptions != null) - { - _ = await _stripeAdapter.SubscriptionUpdateAsync( - subscriber.GatewaySubscriptionId, - subscriptionUpdateOptions); - } - } - } - else - { - if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) && - customer.Subscriptions.Any(sub => - sub.Id == subscriber.GatewaySubscriptionId && - !sub.AutomaticTax.Enabled) && - customer.HasTaxLocationVerified()) - { - var subscriptionUpdateOptions = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, - DefaultTaxRates = [] - }; - - _ = await _stripeAdapter.SubscriptionUpdateAsync( - subscriber.GatewaySubscriptionId, - subscriptionUpdateOptions); - } - } - } - catch - { - if (braintreeCustomer != null && !hadBtCustomer) - { - await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); - } - throw; - } - - return createdCustomer; - } - public async Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount) { Customer customer = null; @@ -1018,7 +765,7 @@ public class StripePaymentService : IPaymentService var address = customer.Address; var taxId = customer.TaxIds?.FirstOrDefault(); - // Line1 is required, so if missing we're using the subscriber name + // Line1 is required, so if missing we're using the subscriber name, // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 if (address != null && string.IsNullOrWhiteSpace(address.Line1)) { diff --git a/src/Core/Tools/Models/Data/SendAccessResult.cs b/src/Core/Tools/Models/Data/SendAccessResult.cs new file mode 100644 index 0000000000..4516f0d9a2 --- /dev/null +++ b/src/Core/Tools/Models/Data/SendAccessResult.cs @@ -0,0 +1,19 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Models.Data; + +/// +/// This enum represents the possible results when attempting to access a . +/// +/// name="Granted">Access is granted for the . +/// name="PasswordRequired">Access is denied, but a password is required to access the . +/// +/// name="PasswordInvalid">Access is denied due to an invalid password. +/// name="Denied">Access is denied for the . +public enum SendAccessResult +{ + Granted, + PasswordRequired, + PasswordInvalid, + Denied +} diff --git a/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs new file mode 100644 index 0000000000..f41c62f409 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs @@ -0,0 +1,52 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; + +namespace Bit.Core.Tools.SendFeatures.Commands; + +public class AnonymousSendCommand : IAnonymousSendCommand +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendAuthorizationService _sendAuthorizationService; + + public AnonymousSendCommand( + ISendRepository sendRepository, + ISendFileStorageService sendFileStorageService, + IPushNotificationService pushNotificationService, + ISendAuthorizationService sendAuthorizationService + ) + { + _sendRepository = sendRepository; + _sendFileStorageService = sendFileStorageService; + _pushNotificationService = pushNotificationService; + _sendAuthorizationService = sendAuthorizationService; + } + + // Response: Send, password required, password invalid + public async Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password) + { + if (send.Type != SendType.File) + { + throw new BadRequestException("Can only get a download URL for a file type of Send"); + } + + var result = _sendAuthorizationService.SendCanBeAccessed(send, password); + + if (!result.Equals(SendAccessResult.Granted)) + { + return (null, result); + } + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), result); + } +} diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs new file mode 100644 index 0000000000..ad23d85170 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs @@ -0,0 +1,21 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces; + +/// +/// AnonymousSendCommand interface provides methods for managing anonymous Sends. +/// +public interface IAnonymousSendCommand +{ + /// + /// Gets the Send file download URL for a Send object. + /// + /// used to help get file download url and validate file + /// FileId get file download url + /// A hashed and base64-encoded password. This is compared with the send's password to authorize access. + /// Async Task object with Tuple containing the string of download url and + /// to determine if the user can access send. + /// + Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password); +} diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs new file mode 100644 index 0000000000..58693e619c --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs @@ -0,0 +1,53 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces; + +/// +/// NonAnonymousSendCommand interface provides methods for managing non-anonymous Sends. +/// +public interface INonAnonymousSendCommand +{ + /// + /// Saves a to the database. + /// + /// that will save to database + /// Task completes as saves to the database + Task SaveSendAsync(Send send); + + /// + /// Saves the and to the database. + /// + /// that will save to the database + /// that will save to file storage + /// Length of file help with saving to file storage + /// Task object for async operations with file upload url + Task SaveFileSendAsync(Send send, SendFileData data, long fileLength); + + /// + /// Upload a file to an existing . + /// + /// of file to be uploaded. The position + /// will be set to 0 before uploading the file. + /// used to help with uploading file + /// Task completes after saving and metadata to the file storage + Task UploadFileToExistingSendAsync(Stream stream, Send send); + + /// + /// Deletes a from the database and file storage. + /// + /// is used to delete from database and file storage + /// Task completes once has been deleted from database and file storage. + Task DeleteSendAsync(Send send); + + /// + /// Stores the confirmed file size of a send; when the file size cannot be confirmed, the send is deleted. + /// + /// The this command acts upon + /// when the file is confirmed, otherwise + /// + /// When a file size cannot be confirmed, we assume we're working with a rogue client. The send is deleted out of + /// an abundance of caution. + /// + Task ConfirmFileSize(Send send); +} diff --git a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs new file mode 100644 index 0000000000..00da0a911f --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.Tools.SendFeatures.Commands; + +public class NonAnonymousSendCommand : INonAnonymousSendCommand +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendValidationService _sendValidationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly ISendCoreHelperService _sendCoreHelperService; + + public NonAnonymousSendCommand(ISendRepository sendRepository, + ISendFileStorageService sendFileStorageService, + IPushNotificationService pushNotificationService, + ISendAuthorizationService sendAuthorizationService, + ISendValidationService sendValidationService, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, + ISendCoreHelperService sendCoreHelperService) + { + _sendRepository = sendRepository; + _sendFileStorageService = sendFileStorageService; + _pushNotificationService = pushNotificationService; + _sendValidationService = sendValidationService; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + _sendCoreHelperService = sendCoreHelperService; + } + + public async Task SaveSendAsync(Send send) + { + // Make sure user can save Sends + await _sendValidationService.ValidateUserCanSaveAsync(send.UserId, send); + + if (send.Id == default(Guid)) + { + await _sendRepository.CreateAsync(send); + await _pushNotificationService.PushSyncSendCreateAsync(send); + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Id = send.UserId ?? default, + Type = ReferenceEventType.SendCreated, + Source = ReferenceEventSource.User, + SendType = send.Type, + MaxAccessCount = send.MaxAccessCount, + HasPassword = !string.IsNullOrWhiteSpace(send.Password), + SendHasNotes = send.Data?.Contains("Notes"), + ClientId = _currentContext.ClientId, + ClientVersion = _currentContext.ClientVersion + }); + } + else + { + send.RevisionDate = DateTime.UtcNow; + await _sendRepository.UpsertAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + } + } + + public async Task SaveFileSendAsync(Send send, SendFileData data, long fileLength) + { + if (send.Type != SendType.File) + { + throw new BadRequestException("Send is not of type \"file\"."); + } + + if (fileLength < 1) + { + throw new BadRequestException("No file data."); + } + + var storageBytesRemaining = await _sendValidationService.StorageRemainingForSendAsync(send); + + if (storageBytesRemaining < fileLength) + { + throw new BadRequestException("Not enough storage available."); + } + + var fileId = _sendCoreHelperService.SecureRandomString(32, useUpperCase: false, useSpecial: false); + + try + { + data.Id = fileId; + data.Size = fileLength; + data.Validated = false; + send.Data = JsonSerializer.Serialize(data, + JsonHelpers.IgnoreWritingNull); + await SaveSendAsync(send); + return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); + } + catch + { + // Clean up since this is not transactional + await _sendFileStorageService.DeleteFileAsync(send, fileId); + throw; + } + } + public async Task UploadFileToExistingSendAsync(Stream stream, Send send) + { + if (stream.Position > 0) + { + stream.Position = 0; + } + + if (send?.Data == null) + { + throw new BadRequestException("Send does not have file data"); + } + + if (send.Type != SendType.File) + { + throw new BadRequestException("Not a File Type Send."); + } + + var data = JsonSerializer.Deserialize(send.Data); + + if (data.Validated) + { + throw new BadRequestException("File has already been uploaded."); + } + + await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id); + + if (!await ConfirmFileSize(send)) + { + throw new BadRequestException("File received does not match expected file length."); + } + } + public async Task DeleteSendAsync(Send send) + { + await _sendRepository.DeleteAsync(send); + if (send.Type == Enums.SendType.File) + { + var data = JsonSerializer.Deserialize(send.Data); + await _sendFileStorageService.DeleteFileAsync(send, data.Id); + } + await _pushNotificationService.PushSyncSendDeleteAsync(send); + } + + public async Task ConfirmFileSize(Send send) + { + var fileData = JsonSerializer.Deserialize(send.Data); + + var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY); + + if (!valid || realSize > SendFileSettingHelper.FILE_SIZE_LEEWAY) + { + // File reported differs in size from that promised. Must be a rogue client. Delete Send + await DeleteSendAsync(send); + return false; + } + + // Update Send data if necessary + if (realSize != fileData.Size) + { + fileData.Size = realSize.Value; + } + fileData.Validated = true; + send.Data = JsonSerializer.Serialize(fileData, + JsonHelpers.IgnoreWritingNull); + await SaveSendAsync(send); + + return valid; + } + +} diff --git a/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs b/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs new file mode 100644 index 0000000000..02327adaac --- /dev/null +++ b/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs @@ -0,0 +1,18 @@ +using Bit.Core.Tools.SendFeatures.Commands; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Tools.SendFeatures; + +public static class SendServiceCollectionExtension +{ + public static void AddSendServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/Tools/Services/Implementations/AzureSendFileStorageService.cs b/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs similarity index 100% rename from src/Core/Tools/Services/Implementations/AzureSendFileStorageService.cs rename to src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs new file mode 100644 index 0000000000..9acf987ac5 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs @@ -0,0 +1,28 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.Services; + +/// +/// Send Authorization service is responsible for checking if a Send can be accessed. +/// +public interface ISendAuthorizationService +{ + /// + /// Checks if a can be accessed while updating the , pushing a notification, and sending a reference event. + /// + /// used to determine access + /// A hashed and base64-encoded password. This is compared with the send's password to authorize access. + /// will be returned to determine if the user can access send. + /// + Task AccessAsync(Send send, string password); + SendAccessResult SendCanBeAccessed(Send send, + string password); + + /// + /// Hashes the password using the password hasher. + /// + /// Password to be hashed + /// Hashed password of the password given + string HashPassword(string password); +} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs new file mode 100644 index 0000000000..a09d7c3c60 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.Tools.Services; + +/// +/// This interface provides helper methods for generating secure random strings. Making +/// it easier to mock the service in unit tests. +/// +public interface ISendCoreHelperService +{ + /// + /// Securely generates a random string of the specified length. + /// + /// Desired string length to be returned + /// Desired casing for the string + /// Determines if special characters will be used in string + /// A secure random string with the desired parameters + string SecureRandomString(int length, bool useUpperCase, bool useSpecial); +} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs new file mode 100644 index 0000000000..29bc0c6a6a --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs @@ -0,0 +1,71 @@ +using Bit.Core.Enums; +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Services; + +/// +/// Send File Storage Service is responsible for uploading, deleting, and validating files +/// whether they are in local storage or in cloud storage. +/// +public interface ISendFileStorageService +{ + FileUploadType FileUploadType { get; } + /// + /// Uploads a new file to the storage. + /// + /// of the file + /// for the file + /// File id + /// Task completes once and have been saved to the database + Task UploadNewFileAsync(Stream stream, Send send, string fileId); + /// + /// Deletes a file from the storage. + /// + /// used to delete file + /// File id of file to be deleted + /// Task completes once has been deleted to the database + Task DeleteFileAsync(Send send, string fileId); + /// + /// Deletes all files for a specific organization. + /// + /// used to delete all files pertaining to organization + /// Task completes after running code to delete files by organization id + Task DeleteFilesForOrganizationAsync(Guid organizationId); + /// + /// Deletes all files for a specific user. + /// + /// used to delete all files pertaining to user + /// Task completes after running code to delete files by user id + Task DeleteFilesForUserAsync(Guid userId); + /// + /// Gets the download URL for a file. + /// + /// used to help get download url for file + /// File id to help get download url for file + /// Download url as a string + Task GetSendFileDownloadUrlAsync(Send send, string fileId); + /// + /// Gets the upload URL for a file. + /// + /// used to help get upload url for file + /// File id to help get upload url for file + /// File upload url as string + Task GetSendFileUploadUrlAsync(Send send, string fileId); + /// + /// Validates the file size of a file in the storage. + /// + /// used to help validate file + /// File id to identify which file to validate + /// Expected file size of the file + /// + /// Send file size tolerance in bytes. When an uploaded file's `expectedFileSize` + /// is outside of the leeway, the storage operation fails. + /// + /// + /// ❌ Fill this in with an explanation of the error thrown when `leeway` is incorrect + /// + /// Task object for async operations with Tuple of boolean that determines if file was valid and long that + /// the actual file size of the file. + /// + Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway); +} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs new file mode 100644 index 0000000000..24d31c5cfe --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs @@ -0,0 +1,35 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Services; + +public interface ISendValidationService +{ + /// + /// Validates a file can be saved by specified user. + /// + /// needed to validate file for specific user + /// needed to help validate file + /// Task completes when a conditional statement has been met it will return out of the method or + /// throw a BadRequestException. + /// + Task ValidateUserCanSaveAsync(Guid? userId, Send send); + + /// + /// Validates a file can be saved by specified user with different policy based on feature flag + /// + /// needed to validate file for specific user + /// needed to help validate file + /// Task completes when a conditional statement has been met it will return out of the method or + /// throw a BadRequestException. + /// + Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send); + + /// + /// Calculates the remaining storage for a Send. + /// + /// needed to help calculate remaining storage + /// Long with the remaining bytes for storage or will throw a BadRequestException if user cannot access + /// file or email is not verified. + /// + Task StorageRemainingForSendAsync(Send send); +} diff --git a/src/Core/Tools/Services/Implementations/LocalSendStorageService.cs b/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs similarity index 100% rename from src/Core/Tools/Services/Implementations/LocalSendStorageService.cs rename to src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs diff --git a/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs b/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs new file mode 100644 index 0000000000..101a33754e --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs @@ -0,0 +1,101 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Tools.Services; + +public class SendAuthorizationService : ISendAuthorizationService +{ + private readonly ISendRepository _sendRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IPushNotificationService _pushNotificationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + + public SendAuthorizationService( + ISendRepository sendRepository, + IPasswordHasher passwordHasher, + IPushNotificationService pushNotificationService, + IReferenceEventService referenceEventService, + ICurrentContext currentContext) + { + _sendRepository = sendRepository; + _passwordHasher = passwordHasher; + _pushNotificationService = pushNotificationService; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + } + + public SendAccessResult SendCanBeAccessed(Send send, + string password) + { + var now = DateTime.UtcNow; + if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled || + send.DeletionDate < now) + { + return SendAccessResult.Denied; + } + if (!string.IsNullOrWhiteSpace(send.Password)) + { + if (string.IsNullOrWhiteSpace(password)) + { + return SendAccessResult.PasswordRequired; + } + var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password); + if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) + { + send.Password = HashPassword(password); + } + if (passwordResult == PasswordVerificationResult.Failed) + { + return SendAccessResult.PasswordInvalid; + } + } + + return SendAccessResult.Granted; + } + + public async Task AccessAsync(Send sendToBeAccessed, string password) + { + var accessResult = SendCanBeAccessed(sendToBeAccessed, password); + + if (!accessResult.Equals(SendAccessResult.Granted)) + { + return accessResult; + } + + if (sendToBeAccessed.Type != SendType.File) + { + // File sends are incremented during file download + sendToBeAccessed.AccessCount++; + } + + await _sendRepository.ReplaceAsync(sendToBeAccessed); + await _pushNotificationService.PushSyncSendUpdateAsync(sendToBeAccessed); + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Id = sendToBeAccessed.UserId ?? default, + Type = ReferenceEventType.SendAccessed, + Source = ReferenceEventSource.User, + SendType = sendToBeAccessed.Type, + MaxAccessCount = sendToBeAccessed.MaxAccessCount, + HasPassword = !string.IsNullOrWhiteSpace(sendToBeAccessed.Password), + SendHasNotes = sendToBeAccessed.Data?.Contains("Notes"), + ClientId = _currentContext.ClientId, + ClientVersion = _currentContext.ClientVersion + }); + return accessResult; + } + + public string HashPassword(string password) + { + return _passwordHasher.HashPassword(new User(), password); + } +} diff --git a/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs b/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs new file mode 100644 index 0000000000..122759f8f0 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs @@ -0,0 +1,12 @@ +using Bit.Core.Utilities; + +namespace Bit.Core.Tools.Services; + +public class SendCoreHelperService : ISendCoreHelperService +{ + public string SecureRandomString(int length, bool useUpperCase, bool useSpecial) + { + return CoreHelpers.SecureRandomString(length, upper: useUpperCase, special: useSpecial); + } + +} diff --git a/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs b/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs new file mode 100644 index 0000000000..ef3f210ff8 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs @@ -0,0 +1,26 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.SendFeatures; + +/// +/// SendFileSettingHelper is a static class that provides constants and helper methods (if needed) for managing file +/// settings. +/// +public static class SendFileSettingHelper +{ + /// + /// The leeway for the file size. This is the calculated 1 megabyte of cushion when doing comparisons of file sizes + /// within the system. + /// + public const long FILE_SIZE_LEEWAY = 1024L * 1024L; // 1MB + /// + /// The maximum file size for a file uploaded in a . Units are calculated in bytes but + /// represent 501 megabytes. 1 megabyte is added for cushion to account for file size. + /// + public const long MAX_FILE_SIZE = Constants.FileSize501mb; + + /// + /// String of the expected file size and to be used when needing to communicate the file size to the client/user. + /// + public const string MAX_FILE_SIZE_READABLE = "500 MB"; +} diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs new file mode 100644 index 0000000000..f1e8855def --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -0,0 +1,142 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tools.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Tools.Services; + +public class SendValidationService : ISendValidationService +{ + + private readonly IUserRepository _userRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPolicyService _policyService; + private readonly IFeatureService _featureService; + private readonly IUserService _userService; + private readonly GlobalSettings _globalSettings; + private readonly ICurrentContext _currentContext; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + + + + public SendValidationService( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IPolicyService policyService, + IFeatureService featureService, + IUserService userService, + IPolicyRequirementQuery policyRequirementQuery, + GlobalSettings globalSettings, + + ICurrentContext currentContext) + { + _userRepository = userRepository; + _organizationRepository = organizationRepository; + _policyService = policyService; + _featureService = featureService; + _userService = userService; + _policyRequirementQuery = policyRequirementQuery; + _globalSettings = globalSettings; + _currentContext = currentContext; + } + + public async Task ValidateUserCanSaveAsync(Guid? userId, Send send) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + await ValidateUserCanSaveAsync_vNext(userId, send); + return; + } + + if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true)) + { + return; + } + + var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, + PolicyType.DisableSend); + if (anyDisableSendPolicies) + { + throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); + } + + if (send.HideEmail.GetValueOrDefault()) + { + var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions); + if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData(p.PolicyData)?.DisableHideEmail ?? false)) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); + } + } + } + + public async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send) + { + if (!userId.HasValue) + { + return; + } + + var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value); + if (disableSendRequirement.DisableSend) + { + throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); + } + + var sendOptionsRequirement = await _policyRequirementQuery.GetAsync(userId.Value); + if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault()) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); + } + } + + public async Task StorageRemainingForSendAsync(Send send) + { + var storageBytesRemaining = 0L; + if (send.UserId.HasValue) + { + var user = await _userRepository.GetByIdAsync(send.UserId.Value); + if (!await _userService.CanAccessPremium(user)) + { + throw new BadRequestException("You must have premium status to use file Sends."); + } + + if (!user.EmailVerified) + { + throw new BadRequestException("You must confirm your email to use file Sends."); + } + + if (user.Premium) + { + storageBytesRemaining = user.StorageBytesRemaining(); + } + else + { + // Users that get access to file storage/premium from their organization get the default + // 1 GB max storage. + short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1; + storageBytesRemaining = user.StorageBytesRemaining(limit); + } + } + else if (send.OrganizationId.HasValue) + { + var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value); + if (!org.MaxStorageGb.HasValue) + { + throw new BadRequestException("This organization cannot use file sends."); + } + + storageBytesRemaining = org.StorageBytesRemaining(); + } + + return storageBytesRemaining; + } +} diff --git a/src/Core/Tools/Services/ISendService.cs b/src/Core/Tools/Services/ISendService.cs deleted file mode 100644 index 2c20851ce8..0000000000 --- a/src/Core/Tools/Services/ISendService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; - -namespace Bit.Core.Tools.Services; - -public interface ISendService -{ - Task DeleteSendAsync(Send send); - Task SaveSendAsync(Send send); - Task SaveFileSendAsync(Send send, SendFileData data, long fileLength); - Task UploadFileToExistingSendAsync(Stream stream, Send send); - Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password); - string HashPassword(string password); - Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password); - Task ValidateSendFile(Send send); -} diff --git a/src/Core/Tools/Services/ISendStorageService.cs b/src/Core/Tools/Services/ISendStorageService.cs deleted file mode 100644 index 4bf2aa3892..0000000000 --- a/src/Core/Tools/Services/ISendStorageService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Enums; -using Bit.Core.Tools.Entities; - -namespace Bit.Core.Tools.Services; - -public interface ISendFileStorageService -{ - FileUploadType FileUploadType { get; } - Task UploadNewFileAsync(Stream stream, Send send, string fileId); - Task DeleteFileAsync(Send send, string fileId); - Task DeleteFilesForOrganizationAsync(Guid organizationId); - Task DeleteFilesForUserAsync(Guid userId); - Task GetSendFileDownloadUrlAsync(Send send, string fileId); - Task GetSendFileUploadUrlAsync(Send send, string fileId); - Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway); -} diff --git a/src/Core/Tools/Services/Implementations/SendService.cs b/src/Core/Tools/Services/Implementations/SendService.cs deleted file mode 100644 index e09787d7eb..0000000000 --- a/src/Core/Tools/Services/Implementations/SendService.cs +++ /dev/null @@ -1,383 +0,0 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -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; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Identity; - -namespace Bit.Core.Tools.Services; - -public class SendService : ISendService -{ - public const long MAX_FILE_SIZE = Constants.FileSize501mb; - public const string MAX_FILE_SIZE_READABLE = "500 MB"; - private readonly ISendRepository _sendRepository; - private readonly IUserRepository _userRepository; - private readonly IPolicyService _policyService; - private readonly IUserService _userService; - private readonly IOrganizationRepository _organizationRepository; - private readonly ISendFileStorageService _sendFileStorageService; - private readonly IPasswordHasher _passwordHasher; - private readonly IPushNotificationService _pushService; - private readonly IReferenceEventService _referenceEventService; - private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; - private readonly IPolicyRequirementQuery _policyRequirementQuery; - private readonly IFeatureService _featureService; - - private const long _fileSizeLeeway = 1024L * 1024L; // 1MB - - public SendService( - ISendRepository sendRepository, - IUserRepository userRepository, - IUserService userService, - IOrganizationRepository organizationRepository, - ISendFileStorageService sendFileStorageService, - IPasswordHasher passwordHasher, - IPushNotificationService pushService, - IReferenceEventService referenceEventService, - GlobalSettings globalSettings, - IPolicyService policyService, - ICurrentContext currentContext, - IPolicyRequirementQuery policyRequirementQuery, - IFeatureService featureService) - { - _sendRepository = sendRepository; - _userRepository = userRepository; - _userService = userService; - _policyService = policyService; - _organizationRepository = organizationRepository; - _sendFileStorageService = sendFileStorageService; - _passwordHasher = passwordHasher; - _pushService = pushService; - _referenceEventService = referenceEventService; - _globalSettings = globalSettings; - _currentContext = currentContext; - _policyRequirementQuery = policyRequirementQuery; - _featureService = featureService; - } - - public async Task SaveSendAsync(Send send) - { - // Make sure user can save Sends - await ValidateUserCanSaveAsync(send.UserId, send); - - if (send.Id == default(Guid)) - { - await _sendRepository.CreateAsync(send); - await _pushService.PushSyncSendCreateAsync(send); - await RaiseReferenceEventAsync(send, ReferenceEventType.SendCreated); - } - else - { - send.RevisionDate = DateTime.UtcNow; - await _sendRepository.UpsertAsync(send); - await _pushService.PushSyncSendUpdateAsync(send); - } - } - - public async Task SaveFileSendAsync(Send send, SendFileData data, long fileLength) - { - if (send.Type != SendType.File) - { - throw new BadRequestException("Send is not of type \"file\"."); - } - - if (fileLength < 1) - { - throw new BadRequestException("No file data."); - } - - var storageBytesRemaining = await StorageRemainingForSendAsync(send); - - if (storageBytesRemaining < fileLength) - { - throw new BadRequestException("Not enough storage available."); - } - - var fileId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); - - try - { - data.Id = fileId; - data.Size = fileLength; - data.Validated = false; - send.Data = JsonSerializer.Serialize(data, - JsonHelpers.IgnoreWritingNull); - await SaveSendAsync(send); - return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); - } - catch - { - // Clean up since this is not transactional - await _sendFileStorageService.DeleteFileAsync(send, fileId); - throw; - } - } - - public async Task UploadFileToExistingSendAsync(Stream stream, Send send) - { - if (send?.Data == null) - { - throw new BadRequestException("Send does not have file data"); - } - - if (send.Type != SendType.File) - { - throw new BadRequestException("Not a File Type Send."); - } - - var data = JsonSerializer.Deserialize(send.Data); - - if (data.Validated) - { - throw new BadRequestException("File has already been uploaded."); - } - - await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id); - - if (!await ValidateSendFile(send)) - { - throw new BadRequestException("File received does not match expected file length."); - } - } - - public async Task ValidateSendFile(Send send) - { - var fileData = JsonSerializer.Deserialize(send.Data); - - var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, _fileSizeLeeway); - - if (!valid || realSize > MAX_FILE_SIZE) - { - // File reported differs in size from that promised. Must be a rogue client. Delete Send - await DeleteSendAsync(send); - return false; - } - - // Update Send data if necessary - if (realSize != fileData.Size) - { - fileData.Size = realSize.Value; - } - fileData.Validated = true; - send.Data = JsonSerializer.Serialize(fileData, - JsonHelpers.IgnoreWritingNull); - await SaveSendAsync(send); - - return valid; - } - - public async Task DeleteSendAsync(Send send) - { - await _sendRepository.DeleteAsync(send); - if (send.Type == Enums.SendType.File) - { - var data = JsonSerializer.Deserialize(send.Data); - await _sendFileStorageService.DeleteFileAsync(send, data.Id); - } - await _pushService.PushSyncSendDeleteAsync(send); - } - - public (bool grant, bool passwordRequiredError, bool passwordInvalidError) SendCanBeAccessed(Send send, - string password) - { - var now = DateTime.UtcNow; - if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || - send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled || - send.DeletionDate < now) - { - return (false, false, false); - } - if (!string.IsNullOrWhiteSpace(send.Password)) - { - if (string.IsNullOrWhiteSpace(password)) - { - return (false, true, false); - } - var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password); - if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) - { - send.Password = HashPassword(password); - } - if (passwordResult == PasswordVerificationResult.Failed) - { - return (false, false, true); - } - } - - return (true, false, false); - } - - // Response: Send, password required, password invalid - public async Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password) - { - if (send.Type != SendType.File) - { - throw new BadRequestException("Can only get a download URL for a file type of Send"); - } - - var (grantAccess, passwordRequired, passwordInvalid) = SendCanBeAccessed(send, password); - - if (!grantAccess) - { - return (null, passwordRequired, passwordInvalid); - } - - send.AccessCount++; - await _sendRepository.ReplaceAsync(send); - await _pushService.PushSyncSendUpdateAsync(send); - return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), false, false); - } - - // Response: Send, password required, password invalid - public async Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password) - { - var send = await _sendRepository.GetByIdAsync(sendId); - var (grantAccess, passwordRequired, passwordInvalid) = SendCanBeAccessed(send, password); - - if (!grantAccess) - { - return (null, passwordRequired, passwordInvalid); - } - - // TODO: maybe move this to a simple ++ sproc? - if (send.Type != SendType.File) - { - // File sends are incremented during file download - send.AccessCount++; - } - - await _sendRepository.ReplaceAsync(send); - await _pushService.PushSyncSendUpdateAsync(send); - await RaiseReferenceEventAsync(send, ReferenceEventType.SendAccessed); - return (send, false, false); - } - - private async Task RaiseReferenceEventAsync(Send send, ReferenceEventType eventType) - { - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Id = send.UserId ?? default, - Type = eventType, - Source = ReferenceEventSource.User, - SendType = send.Type, - MaxAccessCount = send.MaxAccessCount, - HasPassword = !string.IsNullOrWhiteSpace(send.Password), - SendHasNotes = send.Data?.Contains("Notes"), - ClientId = _currentContext.ClientId, - ClientVersion = _currentContext.ClientVersion - }); - } - - public string HashPassword(string password) - { - return _passwordHasher.HashPassword(new User(), password); - } - - private async Task ValidateUserCanSaveAsync(Guid? userId, Send send) - { - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) - { - await ValidateUserCanSaveAsync_vNext(userId, send); - return; - } - - if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true)) - { - return; - } - - var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, - PolicyType.DisableSend); - if (anyDisableSendPolicies) - { - throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); - } - - if (send.HideEmail.GetValueOrDefault()) - { - var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions); - if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData(p.PolicyData)?.DisableHideEmail ?? false)) - { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); - } - } - } - - private async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send) - { - if (!userId.HasValue) - { - return; - } - - var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value); - if (disableSendRequirement.DisableSend) - { - throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); - } - - var sendOptionsRequirement = await _policyRequirementQuery.GetAsync(userId.Value); - if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault()) - { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); - } - } - - private async Task StorageRemainingForSendAsync(Send send) - { - var storageBytesRemaining = 0L; - if (send.UserId.HasValue) - { - var user = await _userRepository.GetByIdAsync(send.UserId.Value); - if (!await _userService.CanAccessPremium(user)) - { - throw new BadRequestException("You must have premium status to use file Sends."); - } - - if (!user.EmailVerified) - { - throw new BadRequestException("You must confirm your email to use file Sends."); - } - - if (user.Premium) - { - storageBytesRemaining = user.StorageBytesRemaining(); - } - else - { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? (short)10240 : (short)1); - } - } - else if (send.OrganizationId.HasValue) - { - var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value); - if (!org.MaxStorageGb.HasValue) - { - throw new BadRequestException("This organization cannot use file sends."); - } - - storageBytesRemaining = org.StorageBytesRemaining(); - } - - return storageBytesRemaining; - } -} diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs index 155abdb4b4..e43eb9a71f 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs @@ -1,6 +1,6 @@ using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs index 69a4be1ef8..cf5ac07ead 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs @@ -1,6 +1,6 @@ using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs index f8448f4198..52977c9d3c 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs @@ -1,6 +1,6 @@ using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index d48fe95096..ba374ae988 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Repositories; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs b/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs index 4271df292a..6d77fd9ed9 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs @@ -2,7 +2,7 @@ namespace Bit.Infrastructure.EntityFramework.Billing.Models; -public class ClientOrganizationMigrationRecord : Core.Billing.Entities.ClientOrganizationMigrationRecord +public class ClientOrganizationMigrationRecord : Core.Billing.Providers.Entities.ClientOrganizationMigrationRecord { } @@ -11,6 +11,6 @@ public class ClientOrganizationMigrationRecordProfile : Profile { public ClientOrganizationMigrationRecordProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs b/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs index 1eea0bf9d2..1bea786f21 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs @@ -4,7 +4,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; namespace Bit.Infrastructure.EntityFramework.Billing.Models; // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global -public class ProviderInvoiceItem : Core.Billing.Entities.ProviderInvoiceItem +public class ProviderInvoiceItem : Core.Billing.Providers.Entities.ProviderInvoiceItem { public virtual Provider Provider { get; set; } } @@ -13,6 +13,6 @@ public class ProviderInvoiceItemMapperProfile : Profile { public ProviderInvoiceItemMapperProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs b/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs index 4dbbfe71d7..c9ba4c813e 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs @@ -4,7 +4,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; namespace Bit.Infrastructure.EntityFramework.Billing.Models; // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global -public class ProviderPlan : Core.Billing.Entities.ProviderPlan +public class ProviderPlan : Core.Billing.Providers.Entities.ProviderPlan { public virtual Provider Provider { get; set; } } @@ -13,6 +13,6 @@ public class ProviderPlanMapperProfile : Profile { public ProviderPlanMapperProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs index c7c9a6118b..4a9a82c9dc 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs @@ -1,6 +1,6 @@ using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs index 87e960e123..ed729070ae 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs @@ -1,6 +1,6 @@ using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using LinqToDB; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs index 386f7115d7..e022527d64 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs @@ -1,6 +1,6 @@ using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index c9f0406a58..22818517d3 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Repositories; using Bit.Core.Enums; using Bit.Core.KeyManagement.Repositories; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 9fbc14444f..598d93b177 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -43,6 +43,7 @@ using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; +using Bit.Core.Tools.SendFeatures; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault; @@ -123,7 +124,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddLoginServices(); services.AddScoped(); services.AddVaultServices(); @@ -132,6 +133,7 @@ public static class ServiceCollectionExtensions services.AddNotificationCenterServices(); services.AddPlatformServices(); services.AddImportServices(); + services.AddSendServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 0b5f5c1f01..44ad5088cd 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -5,7 +5,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 7d0a57ea45..3484c9a995 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -21,7 +21,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs index 8ddd92a5fa..c7c749effd 100644 --- a/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs @@ -6,7 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 36990c7f9a..a082caa469 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -1,14 +1,17 @@ using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Commercial.Core.Billing.Providers.Services; +using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -285,6 +288,19 @@ public class ProviderBillingControllerTests Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } }, TaxIds = new StripeList { Data = [new TaxId { Value = "123456789" }] } }, + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + }, + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } + } + ] + }, Status = "unpaid", }; @@ -330,11 +346,21 @@ public class ProviderBillingControllerTests } }; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe) + .Returns(true); + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); foreach (var providerPlan in providerPlans) { - sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(StaticStore.GetPlan(providerPlan.PlanType)); + var plan = StaticStore.GetPlan(providerPlan.PlanType); + sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(plan); + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); + sutProvider.GetDependency().PriceGetAsync(priceId) + .Returns(new Price + { + UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100 + }); } var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); diff --git a/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs index 842343ba33..7bab587cf0 100644 --- a/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs @@ -23,11 +23,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_Success() { // Arrange - var sendService = Substitute.For(); + var sendAuthorizationService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendService, + sendAuthorizationService, sendRepository ); @@ -52,11 +52,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_SendNotReturnedFromRepository_NotIncludedInOutput() { // Arrange - var sendService = Substitute.For(); + var sendAuthorizationService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendService, + sendAuthorizationService, sendRepository ); @@ -76,11 +76,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_InputMissingUserSend_Throws() { // Arrange - var sendService = Substitute.For(); + var sendAuthorizationService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendService, + sendAuthorizationService, sendRepository ); diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index f784448e50..b1fa5c9260 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -10,7 +10,9 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; @@ -26,7 +28,9 @@ public class SendsControllerTests : IDisposable private readonly GlobalSettings _globalSettings; private readonly IUserService _userService; private readonly ISendRepository _sendRepository; - private readonly ISendService _sendService; + private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; + private readonly IAnonymousSendCommand _anonymousSendCommand; + private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; private readonly ICurrentContext _currentContext; @@ -35,7 +39,9 @@ public class SendsControllerTests : IDisposable { _userService = Substitute.For(); _sendRepository = Substitute.For(); - _sendService = Substitute.For(); + _nonAnonymousSendCommand = Substitute.For(); + _anonymousSendCommand = Substitute.For(); + _sendAuthorizationService = Substitute.For(); _sendFileStorageService = Substitute.For(); _globalSettings = new GlobalSettings(); _logger = Substitute.For>(); @@ -44,7 +50,9 @@ public class SendsControllerTests : IDisposable _sut = new SendsController( _sendRepository, _userService, - _sendService, + _sendAuthorizationService, + _anonymousSendCommand, + _nonAnonymousSendCommand, _sendFileStorageService, _logger, _globalSettings, @@ -68,7 +76,8 @@ public class SendsControllerTests : IDisposable send.Data = JsonSerializer.Serialize(new Dictionary()); send.HideEmail = true; - _sendService.AccessAsync(id, null).Returns((send, false, false)); + _sendRepository.GetByIdAsync(Arg.Any()).Returns(send); + _sendAuthorizationService.AccessAsync(send, null).Returns(SendAccessResult.Granted); _userService.GetUserByIdAsync(Arg.Any()).Returns(user); var request = new SendAccessRequestModel(); diff --git a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs index 59fb35d32e..8049667011 100644 --- a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs +++ b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs @@ -34,11 +34,11 @@ public class SendRequestModelTests Type = SendType.Text, }; - var sendService = Substitute.For(); - sendService.HashPassword(Arg.Any()) + var sendAuthorizationService = Substitute.For(); + sendAuthorizationService.HashPassword(Arg.Any()) .Returns((info) => $"hashed_{(string)info[0]}"); - var send = sendRequest.ToSend(Guid.NewGuid(), sendService); + var send = sendRequest.ToSend(Guid.NewGuid(), sendAuthorizationService); Assert.Equal(deletionDate, send.DeletionDate); Assert.False(send.Disabled); diff --git a/test/Billing.Test/Services/ProviderEventServiceTests.cs b/test/Billing.Test/Services/ProviderEventServiceTests.cs index e080dd8288..7d95157bd2 100644 --- a/test/Billing.Test/Services/ProviderEventServiceTests.cs +++ b/test/Billing.Test/Services/ProviderEventServiceTests.cs @@ -4,10 +4,10 @@ using Bit.Billing.Test.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Utilities; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ProviderClientOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ProviderClientOrganizationSignUpCommandTests.cs new file mode 100644 index 0000000000..b13c7e5b65 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ProviderClientOrganizationSignUpCommandTests.cs @@ -0,0 +1,169 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations.OrganizationSignUp; + +[SutProviderCustomize] +public class ProviderClientOrganizationSignUpCommandTests +{ + [Theory] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + public async Task SignupClientAsync_ValidParameters_CreatesOrganizationSuccessfully( + PlanType planType, + OrganizationSignup signup, + string collectionName, + SutProvider sutProvider) + { + signup.Plan = planType; + signup.AdditionalSeats = 15; + signup.CollectionName = collectionName; + + var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns(plan); + + var result = await sutProvider.Sut.SignUpClientOrganizationAsync(signup); + + Assert.NotNull(result.Organization); + Assert.NotNull(result.DefaultCollection); + Assert.Equal(collectionName, result.DefaultCollection.Name); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(o => + o.Name == signup.Name && + o.BillingEmail == signup.BillingEmail && + o.PlanType == plan.Type && + o.Seats == signup.AdditionalSeats && + o.MaxCollections == plan.PasswordManager.MaxCollections && + o.UsePasswordManager == true && + o.UseSecretsManager == false && + o.Status == OrganizationStatusType.Created + ) + ); + + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.Signup && + referenceEvent.PlanName == plan.Name && + referenceEvent.PlanType == plan.Type && + referenceEvent.Seats == result.Organization.Seats && + referenceEvent.Storage == result.Organization.MaxStorageGb && + referenceEvent.SignupInitiationPath == signup.InitiationPath + )); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(c => + c.Name == collectionName && + c.OrganizationId == result.Organization.Id + ), + Arg.Any>(), + Arg.Any>() + ); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(k => + k.OrganizationId == result.Organization.Id && + k.Type == OrganizationApiKeyType.Default + ) + ); + + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(Arg.Is(o => o.Id == result.Organization.Id)); + } + + [Theory] + [BitAutoData] + public async Task SignupClientAsync_NullPlan_ThrowsBadRequestException( + OrganizationSignup signup, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns((Plan)null); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpClientOrganizationAsync(signup)); + + Assert.Contains(ProviderClientOrganizationSignUpCommand.PlanNullErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignupClientAsync_NegativeAdditionalSeats_ThrowsBadRequestException( + OrganizationSignup signup, + SutProvider sutProvider) + { + signup.Plan = PlanType.TeamsMonthly; + signup.AdditionalSeats = -5; + + var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns(plan); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpClientOrganizationAsync(signup)); + + Assert.Contains(ProviderClientOrganizationSignUpCommand.AdditionalSeatsNegativeErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task SignupClientAsync_WhenExceptionIsThrown_CleanupIsPerformed( + PlanType planType, + OrganizationSignup signup, + SutProvider sutProvider) + { + signup.Plan = planType; + + var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns(plan); + + sutProvider.GetDependency() + .When(x => x.CreateAsync(Arg.Any())) + .Do(_ => throw new Exception()); + + var thrownException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpClientOrganizationAsync(signup)); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(Arg.Is(o => o.Name == signup.Name)); + + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index c138cfac2e..18f1f79900 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -9,7 +9,6 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Context; -using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -177,47 +176,6 @@ public class OrganizationServiceTests referenceEvent.Users == expectedNewUsersCount)); } - [Theory, BitAutoData] - public async Task SignupClientAsync_Succeeds( - OrganizationSignup signup, - SutProvider sutProvider) - { - signup.Plan = PlanType.TeamsMonthly; - - var plan = StaticStore.GetPlan(signup.Plan); - - sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(plan); - - var (organization, _, _) = await sutProvider.Sut.SignupClientAsync(signup); - - await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(org => - org.Id == organization.Id && - org.Name == signup.Name && - org.Plan == plan.Name && - org.PlanType == plan.Type && - org.UsePolicies == plan.HasPolicies && - org.PublicKey == signup.PublicKey && - org.PrivateKey == signup.PrivateKey && - org.UseSecretsManager == false)); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Is(orgApiKey => - orgApiKey.OrganizationId == organization.Id)); - - await sutProvider.GetDependency().Received(1) - .UpsertOrganizationAbilityAsync(organization); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Is(c => c.Name == signup.CollectionName && c.OrganizationId == organization.Id), null, null); - - await sutProvider.GetDependency().Received(1).RaiseEventAsync(Arg.Is( - re => - re.Type == ReferenceEventType.Signup && - re.PlanType == plan.Type)); - } - [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index b1f78ed987..3fb134fda8 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -3,14 +3,11 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Test.Billing.Tax.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Braintree; @@ -195,7 +192,7 @@ public class SubscriberServiceTests await stripeAdapter .DidNotReceiveWithAnyArgs() - .SubscriptionCancelAsync(Arg.Any(), Arg.Any()); ; + .SubscriptionCancelAsync(Arg.Any(), Arg.Any()); } #endregion @@ -1029,7 +1026,7 @@ public class SubscriberServiceTests stripeAdapter .PaymentMethodListAutoPagingAsync(Arg.Any()) - .Returns(GetPaymentMethodsAsync(new List())); + .Returns(GetPaymentMethodsAsync(new List())); await sutProvider.Sut.RemovePaymentSource(organization); @@ -1061,7 +1058,7 @@ public class SubscriberServiceTests stripeAdapter .PaymentMethodListAutoPagingAsync(Arg.Any()) - .Returns(GetPaymentMethodsAsync(new List + .Returns(GetPaymentMethodsAsync(new List { new () { @@ -1086,8 +1083,8 @@ public class SubscriberServiceTests .PaymentMethodDetachAsync(cardId); } - private static async IAsyncEnumerable GetPaymentMethodsAsync( - IEnumerable paymentMethods) + private static async IAsyncEnumerable GetPaymentMethodsAsync( + IEnumerable paymentMethods) { foreach (var paymentMethod in paymentMethods) { @@ -1598,14 +1595,22 @@ public class SubscriberServiceTests City = "Example Town", State = "NY" }, - TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } + TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] }, + Subscriptions = new StripeList + { + Data = [ + new Subscription + { + Id = provider.GatewaySubscriptionId, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false } + } + ] + } }); var subscription = new Subscription { Items = new StripeList() }; sutProvider.GetDependency().SubscriptionGetAsync(Arg.Any()) .Returns(subscription); - sutProvider.GetDependency().CreateAsync(Arg.Any()) - .Returns(new FakeAutomaticTaxStrategy(true)); await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation); @@ -1623,6 +1628,98 @@ public class SubscriberServiceTests await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is( options => options.Type == "us_ein" && options.Value == taxInformation.TaxId)); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + Arg.Is(options => options.AutomaticTax.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task UpdateTaxInformation_NonUser_ReverseCharge_MakesCorrectInvocations( + Provider provider, + SutProvider sutProvider) + { + var stripeAdapter = sutProvider.GetDependency(); + + var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } }; + + stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( + options => options.Expand.Contains("tax_ids"))).Returns(customer); + + var taxInformation = new TaxInformation( + "CA", + "12345", + "123456789", + "us_ein", + "123 Example St.", + null, + "Example Town", + "NY"); + + sutProvider.GetDependency() + .CustomerUpdateAsync( + Arg.Is(p => p == provider.GatewayCustomerId), + Arg.Is(options => + options.Address.Country == "CA" && + options.Address.PostalCode == "12345" && + options.Address.Line1 == "123 Example St." && + options.Address.Line2 == null && + options.Address.City == "Example Town" && + options.Address.State == "NY")) + .Returns(new Customer + { + Id = provider.GatewayCustomerId, + Address = new Address + { + Country = "CA", + PostalCode = "12345", + Line1 = "123 Example St.", + Line2 = null, + City = "Example Town", + State = "NY" + }, + TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] }, + Subscriptions = new StripeList + { + Data = [ + new Subscription + { + Id = provider.GatewaySubscriptionId, + CustomerId = provider.GatewayCustomerId, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false } + } + ] + } + }); + + var subscription = new Subscription { Items = new StripeList() }; + sutProvider.GetDependency().SubscriptionGetAsync(Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + + await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation); + + await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + options => + options.Address.Country == taxInformation.Country && + options.Address.PostalCode == taxInformation.PostalCode && + options.Address.Line1 == taxInformation.Line1 && + options.Address.Line2 == taxInformation.Line2 && + options.Address.City == taxInformation.City && + options.Address.State == taxInformation.State)); + + await stripeAdapter.Received(1).TaxIdDeleteAsync(provider.GatewayCustomerId, "tax_id_1"); + + await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is( + options => options.Type == "us_ein" && + options.Value == taxInformation.TaxId)); + + await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, + Arg.Is(options => options.TaxExempt == StripeConstants.TaxExempt.Reverse)); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + Arg.Is(options => options.AutomaticTax.Enabled == true)); } #endregion diff --git a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs index 8de51b1745..d9d2679bca 100644 --- a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs +++ b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Test.Common.AutoFixture; diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index fa1dd60617..7d8a059d76 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; diff --git a/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs new file mode 100644 index 0000000000..3101273225 --- /dev/null +++ b/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands; +using Bit.Core.Tools.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +public class AnonymousSendCommandTests +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendAuthorizationService _sendAuthorizationService; + private readonly AnonymousSendCommand _anonymousSendCommand; + + public AnonymousSendCommandTests() + { + _sendRepository = Substitute.For(); + _sendFileStorageService = Substitute.For(); + _pushNotificationService = Substitute.For(); + _sendAuthorizationService = Substitute.For(); + + _anonymousSendCommand = new AnonymousSendCommand( + _sendRepository, + _sendFileStorageService, + _pushNotificationService, + _sendAuthorizationService); + } + + [Fact] + public async Task GetSendFileDownloadUrlAsync_Success_ReturnsDownloadUrl() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + AccessCount = 0, + Data = JsonSerializer.Serialize(new { Id = "fileId123" }) + }; + var fileId = "fileId123"; + var password = "testPassword"; + var expectedUrl = "https://example.com/download"; + + _sendAuthorizationService + .SendCanBeAccessed(send, password) + .Returns(SendAccessResult.Granted); + + _sendFileStorageService + .GetSendFileDownloadUrlAsync(send, fileId) + .Returns(expectedUrl); + + // Act + var result = + await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password); + + // Assert + Assert.Equal(expectedUrl, result.Item1); + Assert.Equal(1, send.AccessCount); + + await _sendRepository.Received(1).ReplaceAsync(send); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + } + + [Fact] + public async Task GetSendFileDownloadUrlAsync_AccessDenied_ReturnsNullWithReasons() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + AccessCount = 0 + }; + var fileId = "fileId123"; + var password = "wrongPassword"; + + _sendAuthorizationService + .SendCanBeAccessed(send, password) + .Returns(SendAccessResult.Denied); + + // Act + var result = + await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password); + + // Assert + Assert.Null(result.Item1); + Assert.Equal(SendAccessResult.Denied, result.Item2); + Assert.Equal(0, send.AccessCount); + + await _sendRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default); + await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncSendUpdateAsync(default); + } + + [Fact] + public async Task GetSendFileDownloadUrlAsync_NotFileSend_ThrowsBadRequestException() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.Text + }; + var fileId = "fileId123"; + var password = "testPassword"; + + // Act & Assert + await Assert.ThrowsAsync(() => + _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password)); + } +} diff --git a/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs new file mode 100644 index 0000000000..15e7d57651 --- /dev/null +++ b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs @@ -0,0 +1,1111 @@ +using System.Text.Json; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.CurrentContextFixtures; +using Bit.Core.Test.Tools.AutoFixture.SendFixtures; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures; +using Bit.Core.Tools.SendFeatures.Commands; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +[SutProviderCustomize] +[CurrentContextCustomize] +[UserSendCustomize] +public class NonAnonymousSendCommandTests +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendAuthorizationService _sendAuthorizationService; + private readonly ISendValidationService _sendValidationService; + private readonly IFeatureService _featureService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly ISendCoreHelperService _sendCoreHelperService; + private readonly NonAnonymousSendCommand _nonAnonymousSendCommand; + + public NonAnonymousSendCommandTests() + { + _sendRepository = Substitute.For(); + _sendFileStorageService = Substitute.For(); + _pushNotificationService = Substitute.For(); + _sendAuthorizationService = Substitute.For(); + _featureService = Substitute.For(); + _sendValidationService = Substitute.For(); + _referenceEventService = Substitute.For(); + _currentContext = Substitute.For(); + _sendCoreHelperService = Substitute.For(); + + _nonAnonymousSendCommand = new NonAnonymousSendCommand( + _sendRepository, + _sendFileStorageService, + _pushNotificationService, + _sendAuthorizationService, + _sendValidationService, + _referenceEventService, + _currentContext, + _sendCoreHelperService + ); + } + + // Disable Send policy check + [Theory] + [InlineData(SendType.File)] + [InlineData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_Applies_throws(SendType sendType) + { + // Arrange + var send = new Send + { + Id = default, + Type = sendType, + UserId = Guid.NewGuid() + }; + + var user = new User + { + Id = send.UserId.Value, + Email = "test@example.com" + }; + + // Configure validation service to throw when DisableSend policy applies + _sendValidationService.ValidateUserCanSaveAsync(send.UserId.Value, send) + .Throws(new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("Enterprise Policy", exception.Message); + + // Verify the validation service was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(send.UserId.Value, send); + + // Verify repository was not called since exception was thrown + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableSend_DoesntApply_success(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + Data = "Text with Notes" + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Configure validation service to NOT throw (policy doesn't apply) + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was checked + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.SendHasNotes == true && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_Applies_throws(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = true + }; + + // Configure validation service to throw when HideEmail policy applies + _sendValidationService.ValidateUserCanSaveAsync(userId, send) + .Throws(new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("hide your email address", exception.Message); + + // Verify validation was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository was not called (exception prevented save) + if (isNewSend) + { + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + } + else + { + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + } + + // Verify push notification wasn't sent + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_DoesntApply_success(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = true // Setting HideEmail to true + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Configure validation service to NOT throw (policy doesn't apply) + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was checked + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.HasPassword == false && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + [Theory] + [InlineData(SendType.File)] + [InlineData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = default, + Type = sendType, + UserId = userId + }; + + // Configure validation service to throw when DisableSend policy applies in vNext implementation + _sendValidationService.ValidateUserCanSaveAsync(userId, send) + .Returns(Task.FromException(new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."))); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("Enterprise Policy", exception.Message); + + // Verify validation service was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository and notification methods were not called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + Data = "Text with Notes" + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Configure validation service to return success for vNext implementation + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Enable feature flag for policy requirements (vNext path) + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was checked with vNext path + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.SendHasNotes == true && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + // Send Options Policy - Disable Hide Email check + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = true + }; + + // Enable feature flag for policy requirements (vNext path) + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Configure validation service to throw when DisableHideEmail policy applies in vNext implementation + _sendValidationService.ValidateUserCanSaveAsync(userId, send) + .Throws(new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("hide your email address", exception.Message); + + // Verify validation was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository was not called (exception prevented save) + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + + // Verify push notification wasn't sent + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + + // Verify reference event service wasn't called + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = false // Email is not hidden, so policy doesn't block + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Enable feature flag for policy requirements (vNext path) + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Configure validation service to allow saves when HideEmail is false + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was called with vNext path + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + [Fact] + public async Task SaveSendAsync_ExistingSend_Updates() + { + // Arrange + var userId = Guid.NewGuid(); + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + Type = SendType.Text, + UserId = userId, + Data = "Some text data" + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository was called with updated send + await _sendRepository.Received(1).UpsertAsync(send); + + // Check that the revision date was updated + Assert.NotEqual(initialDate, send.RevisionDate); + + // Verify push notification was sent for the update + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + + // Verify no reference event was raised (only happens for new sends) + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_TextType_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.Text, // Text type instead of File + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("not of type \"file\"", exception.Message); + + // Verify no further methods were called + await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any()); + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_EmptyFile_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 0L; // Empty file + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("No file data", exception.Message); + + // Verify no methods were called after validation failed + await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any()); + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCannotAccessPremium_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to throw when checking storage + _sendValidationService.StorageRemainingForSendAsync(send) + .Throws(new BadRequestException("You must have premium status to use file Sends.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("premium status", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserHasUnconfirmedEmail_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to pass storage check + _sendValidationService.StorageRemainingForSendAsync(send).Returns(10240L); // 10KB remaining + + // Configure validation service to throw when checking user can save + _sendValidationService.When(x => x.ValidateUserCanSaveAsync(userId, send)) + .Throw(new BadRequestException("You must confirm your email before creating a Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("confirm your email", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify SaveSendAsync attempted to be called, triggering email validation + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify no repository or notification methods were called after validation failed + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_HasNoStorage_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to return 0 storage remaining + _sendValidationService.StorageRemainingForSendAsync(send).Returns(0L); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_StorageFull_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to return less storage remaining than needed + _sendValidationService.StorageRemainingForSendAsync(send).Returns(512L); // Only 512 bytes available + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsSelfHosted_GiantFile_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 15L * 1024L * 1024L * 1024L; // 15GB + + // Configure validation service to return large but insufficient storage (10GB for self-hosted non-premium) + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(10L * 1024L * 1024L * 1024L); // 10GB remaining (self-hosted default) + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_TwoGigabyteFile_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB + + // Configure validation service to return 1GB storage (cloud non-premium default) + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining (cloud default) + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_ThrowsBadRequest() + { + // Arrange + var organizationId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + OrganizationId = organizationId + }; + + var fileData = new SendFileData + { + FileName = "test.txt" + }; + + const long fileLength = 1000; + + // Set up validation service to return 0 storage remaining + // This simulates the case when an organization's max storage is null + _sendValidationService.StorageRemainingForSendAsync(send).Returns(0L); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Equal("Not enough storage available.", exception.Message); + + // Verify the method was called exactly once + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + } + + [Fact] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_TwoGBFile_ThrowsBadRequest() + { + // Arrange + var orgId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + OrganizationId = orgId, + UserId = null + }; + var fileData = new SendFileData(); + var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB + + // Configure validation service to throw BadRequest when checking storage for org without storage + _sendValidationService.StorageRemainingForSendAsync(send) + .Throws(new BadRequestException("This organization cannot use file sends.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("This organization cannot use file sends", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsOneGB_TwoGBFile_ThrowsBadRequest() + { + // Arrange + var orgId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + OrganizationId = orgId, + UserId = null + }; + var fileData = new SendFileData(); + var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB + + // Configure validation service to return 1GB storage (org's max storage limit) + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_HasEnoughStorage_Success() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 500L * 1024L; // 500KB + var expectedFileId = "generatedfileid"; + var expectedUploadUrl = "https://upload.example.com/url"; + + // Configure storage validation to return more storage than needed + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1024L * 1024L); // 1MB remaining + + // Configure file storage service to return upload URL + _sendFileStorageService.GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()) + .Returns(expectedUploadUrl); + + // Set up string generator to return predictable file ID + _sendCoreHelperService.SecureRandomString(32, false, false) + .Returns(expectedFileId); + + // Act + var result = await _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength); + + // Assert + Assert.Equal(expectedUploadUrl, result); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify upload URL was requested + await _sendFileStorageService.Received(1).GetSendFileUploadUrlAsync(send, expectedFileId); + } + + [Fact] + public async Task SaveFileSendAsync_HasEnoughStorage_SendFileThrows_CleansUp() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 500L * 1024L; // 500KB + var expectedFileId = "generatedfileid"; + + // Configure storage validation to return more storage than needed + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1024L * 1024L); // 1MB remaining + + // Set up string generator to return predictable file ID + _sendCoreHelperService.SecureRandomString(32, false, false) + .Returns(expectedFileId); + + // Configure file storage service to throw exception when getting upload URL + _sendFileStorageService.GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()) + .Throws(new Exception("Storage service unavailable")); + + // Act & Assert + await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify file was cleaned up after failure + await _sendFileStorageService.Received(1).DeleteFileAsync(send, expectedFileId); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_SendNull_ThrowsBadRequest() + { + // Arrange + Stream stream = new MemoryStream(); + Send send = null; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("Send does not have file data", exception.Message); + + // Verify no interactions with storage service + await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_SendDataNull_ThrowsBadRequest() + { + // Arrange + Stream stream = new MemoryStream(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = Guid.NewGuid(), + Data = null // Send exists but has null Data property + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("Send does not have file data", exception.Message); + + // Verify no interactions with storage service + await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_NotFileType_ThrowsBadRequest() + { + // Arrange + Stream stream = new MemoryStream(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.Text, // Not a file type + UserId = Guid.NewGuid(), + Data = "{\"someData\":\"value\"}" // Has data, but not file data + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("Not a File Type Send.", exception.Message); + + // Verify no interactions with storage service + await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_StreamPositionRestToZero_Success() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + stream.Position = 2; + var sendId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var fileId = "existingfileid123"; + + var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; + var send = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(sendFileData) + }; + + // Setup validation to succeed + _sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size)); + + // Act + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); + + // Assert + // Verify file was uploaded with correct parameters + await _sendFileStorageService.Received(1).UploadNewFileAsync( + Arg.Is(s => s == stream && s.Position == 0), // Ensure stream position is reset + Arg.Is(s => s.Id == sendId && s.UserId == userId), + Arg.Is(id => id == fileId) + ); + } + + + [Fact] + public async Task UploadFileToExistingSendAsync_Success() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + stream.Position = 2; // Simulate a non-zero position + var sendId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var fileId = "existingfileid123"; + + var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; + var send = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(sendFileData) + }; + + _sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size)); + + // Act + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); + + // Assert + // Verify file was uploaded with correct parameters + await _sendFileStorageService.Received(1).UploadNewFileAsync( + Arg.Is(s => s == stream && s.Position == 0), // Ensure stream position is reset + Arg.Is(s => s.Id == sendId && s.UserId == userId), + Arg.Is(id => id == fileId) + ); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_InvalidSize_ThrowsBadRequest() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + var sendId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var fileId = "existingfileid123"; + + var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; + var send = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(sendFileData) + }; + + // Configure storage service to upload successfully + _sendFileStorageService.UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Configure validation to fail due to file size mismatch + _nonAnonymousSendCommand.ConfirmFileSize(send) + .Returns(false); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("File received does not match expected file length.", exception.Message); + } +} diff --git a/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs b/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs new file mode 100644 index 0000000000..9b2637d030 --- /dev/null +++ b/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs @@ -0,0 +1,175 @@ +using Bit.Core.Context; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.Services; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +public class SendAuthorizationServiceTests +{ + private readonly ISendRepository _sendRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IPushNotificationService _pushNotificationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly SendAuthorizationService _sendAuthorizationService; + + public SendAuthorizationServiceTests() + { + _sendRepository = Substitute.For(); + _passwordHasher = Substitute.For>(); + _pushNotificationService = Substitute.For(); + _referenceEventService = Substitute.For(); + _currentContext = Substitute.For(); + + _sendAuthorizationService = new SendAuthorizationService( + _sendRepository, + _passwordHasher, + _pushNotificationService, + _referenceEventService, + _currentContext); + } + + + [Fact] + public void SendCanBeAccessed_Success_ReturnsTrue() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = 10, + AccessCount = 5, + ExpirationDate = DateTime.UtcNow.AddYears(1), + DeletionDate = DateTime.UtcNow.AddYears(1), + Disabled = false, + Password = "hashedPassword123" + }; + + const string password = "TEST"; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), send.Password, password) + .Returns(PasswordVerificationResult.Success); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(send, password); + + // Assert + Assert.Equal(SendAccessResult.Granted, result); + } + + [Fact] + public void SendCanBeAccessed_NullMaxAccess_Success() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = null, + AccessCount = 5, + ExpirationDate = DateTime.UtcNow.AddYears(1), + DeletionDate = DateTime.UtcNow.AddYears(1), + Disabled = false, + Password = "hashedPassword123" + }; + + const string password = "TEST"; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), send.Password, password) + .Returns(PasswordVerificationResult.Success); + + // Act + var result = _sendAuthorizationService.SendCanBeAccessed(send, password); + + // Assert + Assert.Equal(SendAccessResult.Granted, result); + } + + [Fact] + public void SendCanBeAccessed_NullSend_DoesNotGrantAccess() + { + // Arrange + _passwordHasher + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Success); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(null, "TEST"); + + // Assert + Assert.Equal(SendAccessResult.Denied, result); + } + + [Fact] + public void SendCanBeAccessed_RehashNeeded_RehashesPassword() + { + // Arrange + var now = DateTime.UtcNow; + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = null, + AccessCount = 5, + ExpirationDate = now.AddYears(1), + DeletionDate = now.AddYears(1), + Disabled = false, + Password = "TEST" + }; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.SuccessRehashNeeded); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(send, "TEST"); + + // Assert + _passwordHasher + .Received(1) + .HashPassword(Arg.Any(), "TEST"); + + Assert.Equal(SendAccessResult.Granted, result); + } + + [Fact] + public void SendCanBeAccessed_VerifyFailed_PasswordInvalidReturnsTrue() + { + // Arrange + var now = DateTime.UtcNow; + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = null, + AccessCount = 5, + ExpirationDate = now.AddYears(1), + DeletionDate = now.AddYears(1), + Disabled = false, + Password = "TEST" + }; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Failed); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(send, "TEST"); + + // Assert + Assert.Equal(SendAccessResult.PasswordInvalid, result); + } +} diff --git a/test/Core.Test/Tools/Services/SendServiceTests.cs b/test/Core.Test/Tools/Services/SendServiceTests.cs deleted file mode 100644 index 86d476340d..0000000000 --- a/test/Core.Test/Tools/Services/SendServiceTests.cs +++ /dev/null @@ -1,867 +0,0 @@ -using System.Text; -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Platform.Push; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Test.AutoFixture.CurrentContextFixtures; -using Bit.Core.Test.Entities; -using Bit.Core.Test.Tools.AutoFixture.SendFixtures; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Identity; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -using GlobalSettings = Bit.Core.Settings.GlobalSettings; - -namespace Bit.Core.Test.Tools.Services; - -[SutProviderCustomize] -[CurrentContextCustomize] -[UserSendCustomize] -public class SendServiceTests -{ - private void SaveSendAsync_Setup(SendType sendType, bool disableSendPolicyAppliesToUser, - SutProvider sutProvider, Send send) - { - send.Id = default; - send.Type = sendType; - - sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.DisableSend).Returns(disableSendPolicyAppliesToUser); - } - - // Disable Send policy check - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_Applies_throws(SendType sendType, - SutProvider sutProvider, Send send) - { - SaveSendAsync_Setup(sendType, disableSendPolicyAppliesToUser: true, sutProvider, send); - - await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_DoesntApply_success(SendType sendType, - SutProvider sutProvider, Send send) - { - SaveSendAsync_Setup(sendType, disableSendPolicyAppliesToUser: false, sutProvider, send); - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - // Send Options Policy - Disable Hide Email check - - private void SaveSendAsync_HideEmail_Setup(bool disableHideEmailAppliesToUser, - SutProvider sutProvider, Send send, Policy policy) - { - send.HideEmail = true; - - var sendOptions = new SendOptionsPolicyData - { - DisableHideEmail = disableHideEmailAppliesToUser - }; - policy.Data = JsonSerializer.Serialize(sendOptions, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }); - - sutProvider.GetDependency().GetPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.SendOptions).Returns(new List() - { - new() { PolicyType = policy.Type, PolicyData = policy.Data, OrganizationId = policy.OrganizationId, PolicyEnabled = policy.Enabled } - }); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_Applies_throws(SendType sendType, - SutProvider sutProvider, Send send, Policy policy) - { - SaveSendAsync_Setup(sendType, false, sutProvider, send); - SaveSendAsync_HideEmail_Setup(true, sutProvider, send, policy); - - await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_DoesntApply_success(SendType sendType, - SutProvider sutProvider, Send send, Policy policy) - { - SaveSendAsync_Setup(sendType, false, sutProvider, send); - SaveSendAsync_HideEmail_Setup(false, sutProvider, send, policy); - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - // Disable Send policy check - vNext - private void SaveSendAsync_Setup_vNext(SutProvider sutProvider, Send send, - DisableSendPolicyRequirement disableSendPolicyRequirement, SendOptionsPolicyRequirement sendOptionsPolicyRequirement) - { - sutProvider.GetDependency().GetAsync(send.UserId!.Value) - .Returns(disableSendPolicyRequirement); - sutProvider.GetDependency().GetAsync(send.UserId!.Value) - .Returns(sendOptionsPolicyRequirement); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - - // Should not be called in these tests - sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( - Arg.Any(), Arg.Any()).ThrowsAsync(); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement { DisableSend = true }, new SendOptionsPolicyRequirement()); - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - Assert.Contains("Due to an Enterprise Policy, you are only able to delete an existing Send.", - exception.Message); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement()); - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - // Send Options Policy - Disable Hide Email check - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true }); - send.HideEmail = true; - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - Assert.Contains("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.", exception.Message); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true }); - send.HideEmail = false; - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_DoesntApply_Success_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement()); - send.HideEmail = true; - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - [Theory] - [BitAutoData] - public async Task SaveSendAsync_ExistingSend_Updates(SutProvider sutProvider, - Send send) - { - send.Id = Guid.NewGuid(); - - var now = DateTime.UtcNow; - await sutProvider.Sut.SaveSendAsync(send); - - Assert.True(send.RevisionDate - now < TimeSpan.FromSeconds(1)); - - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .PushSyncSendUpdateAsync(send); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_TextType_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - send.Type = SendType.Text; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 0) - ); - - Assert.Contains("not of type \"file\"", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_EmptyFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - send.Type = SendType.File; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 0) - ); - - Assert.Contains("no file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCannotAccessPremium_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(false); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("must have premium", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserHasUnconfirmedEmail_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = false, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("must confirm your email", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_HasNoStorage_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = true, - MaxStorageGb = null, - Storage = 0, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_StorageFull_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = true, - MaxStorageGb = 2, - Storage = 2 * UserTests.Multiplier, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsSelfHosted_GiantFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = false, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .SelfHosted = true; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 11000 * UserTests.Multiplier) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_TwoGigabyteFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = false, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .SelfHosted = false; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 2 * UserTests.Multiplier) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var org = new Organization - { - Id = Guid.NewGuid(), - MaxStorageGb = null, - }; - - send.UserId = null; - send.OrganizationId = org.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(org.Id) - .Returns(org); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("organization cannot use file sends", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_TwoGBFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var org = new Organization - { - Id = Guid.NewGuid(), - MaxStorageGb = null, - }; - - send.UserId = null; - send.OrganizationId = org.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(org.Id) - .Returns(org); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("organization cannot use file sends", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsOneGB_TwoGBFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var org = new Organization - { - Id = Guid.NewGuid(), - MaxStorageGb = 1, - }; - - send.UserId = null; - send.OrganizationId = org.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(org.Id) - .Returns(org); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 2 * UserTests.Multiplier) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_HasEnoughStorage_Success(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - MaxStorageGb = 10, - }; - - var data = new SendFileData - { - - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - var testUrl = "https://test.com/"; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .GetSendFileUploadUrlAsync(send, Arg.Any()) - .Returns(testUrl); - - var utcNow = DateTime.UtcNow; - - var url = await sutProvider.Sut.SaveFileSendAsync(send, data, 1 * UserTests.Multiplier); - - Assert.Equal(testUrl, url); - Assert.True(send.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - - await sutProvider.GetDependency() - .Received(1) - .GetSendFileUploadUrlAsync(send, Arg.Any()); - - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .PushSyncSendUpdateAsync(send); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_HasEnoughStorage_SendFileThrows_CleansUp(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - MaxStorageGb = 10, - }; - - var data = new SendFileData - { - - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .GetSendFileUploadUrlAsync(send, Arg.Any()) - .Returns(callInfo => throw new Exception("Problem")); - - var utcNow = DateTime.UtcNow; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, data, 1 * UserTests.Multiplier) - ); - - Assert.True(send.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - Assert.Equal("Problem", exception.Message); - - await sutProvider.GetDependency() - .Received(1) - .GetSendFileUploadUrlAsync(send, Arg.Any()); - - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .PushSyncSendUpdateAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .DeleteFileAsync(send, Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_SendNull_ThrowsBadRequest(SutProvider sutProvider) - { - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), null) - ); - - Assert.Contains("does not have file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_SendDataNull_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - send.Data = null; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), send) - ); - - Assert.Contains("does not have file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_NotFileType_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), send) - ); - - Assert.Contains("not a file type send", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_Success(SutProvider sutProvider, - Send send) - { - var fileContents = "Test file content"; - - var sendFileData = new SendFileData - { - Id = "TEST", - Size = fileContents.Length, - Validated = false, - }; - - send.Type = SendType.File; - send.Data = JsonSerializer.Serialize(sendFileData); - - sutProvider.GetDependency() - .ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, Arg.Any()) - .Returns((true, sendFileData.Size)); - - await sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(Encoding.UTF8.GetBytes(fileContents)), send); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_InvalidSize(SutProvider sutProvider, - Send send) - { - var fileContents = "Test file content"; - - var sendFileData = new SendFileData - { - Id = "TEST", - Size = fileContents.Length, - }; - - send.Type = SendType.File; - send.Data = JsonSerializer.Serialize(sendFileData); - - sutProvider.GetDependency() - .ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, Arg.Any()) - .Returns((false, sendFileData.Size)); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(Encoding.UTF8.GetBytes(fileContents)), send) - ); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_Success(SutProvider sutProvider, Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = 10; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), send.Password, "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - Assert.True(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_NullMaxAccess_Success(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), send.Password, "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - Assert.True(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_NullSend_DoesNotGrantAccess(SutProvider sutProvider) - { - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(null, "TEST"); - - Assert.False(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_NullPassword_PasswordRequiredErrorReturnsTrue(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - send.Password = "HASH"; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, null); - - Assert.False(grant); - Assert.True(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_RehashNeeded_RehashesPassword(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - send.Password = "TEST"; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.SuccessRehashNeeded); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - sutProvider.GetDependency>() - .Received(1) - .HashPassword(Arg.Any(), "TEST"); - - Assert.True(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_VerifyFailed_PasswordInvalidReturnsTrue(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - send.Password = "TEST"; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Failed); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - Assert.False(grant); - Assert.False(passwordRequiredError); - Assert.True(passwordInvalidError); - } -}