mirror of
https://github.com/bitwarden/server.git
synced 2025-12-10 17:45:21 -06:00
Merge branch 'main' into ac/pm-28487/server-update-github-codeowners-for-event-projects
This commit is contained in:
commit
9861eb5662
@ -16,7 +16,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
|
||||
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
|
||||
|
||||
<XUnitVersion>2.6.6</XUnitVersion>
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ using Bit.Api.Models.Response;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Context;
|
||||
@ -35,7 +34,7 @@ public class TwoFactorController : Controller
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly UserManager<User> _userManager;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IDuoUniversalTokenService _duoUniversalTokenService;
|
||||
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
|
||||
@ -47,7 +46,7 @@ public class TwoFactorController : Controller
|
||||
IOrganizationService organizationService,
|
||||
UserManager<User> userManager,
|
||||
ICurrentContext currentContext,
|
||||
IVerifyAuthRequestCommand verifyAuthRequestCommand,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IDuoUniversalTokenService duoUniversalConfigService,
|
||||
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector,
|
||||
@ -58,7 +57,7 @@ public class TwoFactorController : Controller
|
||||
_organizationService = organizationService;
|
||||
_userManager = userManager;
|
||||
_currentContext = currentContext;
|
||||
_verifyAuthRequestCommand = verifyAuthRequestCommand;
|
||||
_authRequestRepository = authRequestRepository;
|
||||
_duoUniversalTokenService = duoUniversalConfigService;
|
||||
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
|
||||
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
|
||||
@ -350,14 +349,15 @@ public class TwoFactorController : Controller
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
// Check if 2FA email is from Passwordless.
|
||||
// Check if 2FA email is from a device approval ("Log in with device") scenario.
|
||||
if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode))
|
||||
{
|
||||
if (await _verifyAuthRequestCommand
|
||||
.VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId),
|
||||
requestModel.AuthRequestAccessCode))
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(requestModel.AuthRequestId));
|
||||
if (authRequest != null &&
|
||||
authRequest.IsValidForAuthentication(user.Id, requestModel.AuthRequestAccessCode))
|
||||
{
|
||||
await _twoFactorEmailService.SendTwoFactorEmailAsync(user);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken))
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
#nullable enable
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -16,6 +14,7 @@ public class AccountsBillingController(
|
||||
IUserService userService,
|
||||
IPaymentHistoryService paymentHistoryService) : Controller
|
||||
{
|
||||
// TODO: Migrate to Query / AccountBillingVNextController
|
||||
[HttpGet("history")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<BillingHistoryResponseModel> GetBillingHistoryAsync()
|
||||
@ -30,20 +29,7 @@ public class AccountsBillingController(
|
||||
return new BillingHistoryResponseModel(billingInfo);
|
||||
}
|
||||
|
||||
[HttpGet("payment-method")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<BillingPaymentResponseModel> GetPaymentMethodAsync()
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var billingInfo = await paymentService.GetBillingAsync(user);
|
||||
return new BillingPaymentResponseModel(billingInfo);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / AccountBillingVNextController
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null)
|
||||
{
|
||||
@ -62,6 +48,7 @@ public class AccountsBillingController(
|
||||
return TypedResults.Ok(invoices);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / AccountBillingVNextController
|
||||
[HttpGet("transactions")]
|
||||
public async Task<IResult> GetTransactionsAsync([FromQuery] DateTime? startAfter = null)
|
||||
{
|
||||
@ -78,18 +65,4 @@ public class AccountsBillingController(
|
||||
|
||||
return TypedResults.Ok(transactions);
|
||||
}
|
||||
|
||||
[HttpPost("preview-invoice")]
|
||||
public async Task<IResult> PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId);
|
||||
|
||||
return TypedResults.Ok(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
@ -29,6 +27,7 @@ public class AccountsController(
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
// TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||
PremiumRequestModel model,
|
||||
@ -76,6 +75,7 @@ public class AccountsController(
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
|
||||
[HttpGet("subscription")]
|
||||
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
|
||||
[FromServices] GlobalSettings globalSettings,
|
||||
@ -114,29 +114,7 @@ public class AccountsController(
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("payment")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostPaymentAsync([FromBody] PaymentRequestModel model)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
await userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType!.Value,
|
||||
new TaxInfo
|
||||
{
|
||||
BillingAddressLine1 = model.Line1,
|
||||
BillingAddressLine2 = model.Line2,
|
||||
BillingAddressCity = model.City,
|
||||
BillingAddressState = model.State,
|
||||
BillingAddressCountry = model.Country,
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
TaxIdNumber = model.TaxId
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
|
||||
[HttpPost("storage")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
|
||||
@ -151,8 +129,11 @@ public class AccountsController(
|
||||
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* TODO: A new version of this exists in the AccountBillingVNextController.
|
||||
* The individual-self-hosting-license-uploader.component needs to be updated to use it.
|
||||
* Then, this can be removed.
|
||||
*/
|
||||
[HttpPost("license")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
public async Task PostLicenseAsync(LicenseRequestModel model)
|
||||
@ -172,6 +153,7 @@ public class AccountsController(
|
||||
await userService.UpdateLicenseAsync(user, license);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as DELETE /account/billing/vnext/subscription
|
||||
[HttpPost("cancel")]
|
||||
public async Task PostCancelAsync(
|
||||
[FromBody] SubscriptionCancellationRequestModel request,
|
||||
@ -189,6 +171,7 @@ public class AccountsController(
|
||||
user.IsExpired());
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
|
||||
[HttpPost("reinstate-premium")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostReinstateAsync()
|
||||
@ -202,41 +185,6 @@ public class AccountsController(
|
||||
await userService.ReinstatePremiumAsync(user);
|
||||
}
|
||||
|
||||
[HttpGet("tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<TaxInfoResponseModel> GetTaxInfoAsync(
|
||||
[FromServices] IPaymentService paymentService)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var taxInfo = await paymentService.GetTaxInfoAsync(user);
|
||||
return new TaxInfoResponseModel(taxInfo);
|
||||
}
|
||||
|
||||
[HttpPut("tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PutTaxInfoAsync(
|
||||
[FromBody] TaxInfoUpdateRequestModel model,
|
||||
[FromServices] IPaymentService paymentService)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var taxInfo = new TaxInfo
|
||||
{
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
BillingAddressCountry = model.Country,
|
||||
};
|
||||
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||
{
|
||||
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("invoices")]
|
||||
[Authorize("Application")]
|
||||
public class InvoicesController : BaseBillingController
|
||||
{
|
||||
[HttpPost("preview-organization")]
|
||||
public async Task<IResult> PreviewInvoiceAsync(
|
||||
[FromBody] PreviewOrganizationInvoiceRequestBody model,
|
||||
[FromServices] ICurrentContext currentContext,
|
||||
[FromServices] IOrganizationRepository organizationRepository,
|
||||
[FromServices] IPaymentService paymentService)
|
||||
{
|
||||
Organization organization = null;
|
||||
if (model.OrganizationId != default)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(model.OrganizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
organization = await organizationRepository.GetByIdAsync(model.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId,
|
||||
organization?.GatewaySubscriptionId);
|
||||
|
||||
return TypedResults.Ok(invoice);
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api.OrganizationLicenses;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("licenses")]
|
||||
[Authorize("Licensing")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class LicensesController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
|
||||
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public LicensesController(
|
||||
IUserRepository userRepository,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
|
||||
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
|
||||
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
[HttpGet("user/{id}")]
|
||||
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(new Guid(id));
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
else if (!user.LicenseKey.Equals(key))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
var license = await _userService.GenerateLicenseAsync(user, null);
|
||||
return license;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used by self-hosted installations to get an updated license file
|
||||
/// </summary>
|
||||
[HttpGet("organization/{id}")]
|
||||
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException("Organization not found.");
|
||||
}
|
||||
|
||||
if (!organization.LicenseKey.Equals(model.LicenseKey))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
|
||||
{
|
||||
throw new BadRequestException("Invalid Billing Sync Key");
|
||||
}
|
||||
|
||||
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
|
||||
return license;
|
||||
}
|
||||
}
|
||||
@ -20,9 +20,9 @@ public class OrganizationBillingController(
|
||||
IOrganizationBillingService organizationBillingService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
ISubscriberService subscriberService,
|
||||
IPaymentHistoryService paymentHistoryService) : BaseBillingController
|
||||
{
|
||||
// TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed.
|
||||
[HttpGet("metadata")]
|
||||
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
@ -41,6 +41,7 @@ public class OrganizationBillingController(
|
||||
return TypedResults.Ok(metadata);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / OrganizationBillingVNextController
|
||||
[HttpGet("history")]
|
||||
public async Task<IResult> GetHistoryAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
@ -61,6 +62,7 @@ public class OrganizationBillingController(
|
||||
return TypedResults.Ok(billingInfo);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / OrganizationBillingVNextController
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null)
|
||||
{
|
||||
@ -85,6 +87,7 @@ public class OrganizationBillingController(
|
||||
return TypedResults.Ok(invoices);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / OrganizationBillingVNextController
|
||||
[HttpGet("transactions")]
|
||||
public async Task<IResult> GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null)
|
||||
{
|
||||
@ -108,6 +111,7 @@ public class OrganizationBillingController(
|
||||
return TypedResults.Ok(transactions);
|
||||
}
|
||||
|
||||
// TODO: Can be removed once we do away with the organization-plans.component.
|
||||
[HttpGet]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IResult> GetBillingAsync(Guid organizationId)
|
||||
@ -131,127 +135,7 @@ public class OrganizationBillingController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("payment-method")]
|
||||
public async Task<IResult> GetPaymentMethodAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var paymentMethod = await subscriberService.GetPaymentMethod(organization);
|
||||
|
||||
var response = PaymentMethodResponse.From(paymentMethod);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("payment-method")]
|
||||
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
[FromBody] UpdatePaymentMethodRequestBody requestBody)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
|
||||
|
||||
var taxInformation = requestBody.TaxInformation.ToDomain();
|
||||
|
||||
await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("payment-method/verify-bank-account")]
|
||||
public async Task<IResult> VerifyBankAccountAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
[FromBody] VerifyBankAccountRequestBody requestBody)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM"))
|
||||
{
|
||||
return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'");
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
await subscriberService.VerifyBankAccount(organization, requestBody.DescriptorCode);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpGet("tax-information")]
|
||||
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(organization);
|
||||
|
||||
var response = TaxInformationResponse.From(taxInformation);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("tax-information")]
|
||||
public async Task<IResult> UpdateTaxInformationAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
[FromBody] TaxInformationRequestBody requestBody)
|
||||
{
|
||||
if (!await currentContext.EditPaymentMethods(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var taxInformation = requestBody.ToDomain();
|
||||
|
||||
await subscriberService.UpdateTaxInformation(organization, taxInformation);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / OrganizationBillingVNextController
|
||||
[HttpPost("setup-business-unit")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IResult> SetupBusinessUnitAsync(
|
||||
@ -280,6 +164,7 @@ public class OrganizationBillingController(
|
||||
return TypedResults.Ok(providerId);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / OrganizationBillingVNextController
|
||||
[HttpPost("change-frequency")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(
|
||||
|
||||
@ -19,7 +19,6 @@ using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -249,53 +248,6 @@ public class OrganizationsController(
|
||||
await organizationService.ReinstateSubscriptionAsync(id);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<TaxInfoResponseModel> GetTaxInfo(Guid id)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
|
||||
return new TaxInfoResponseModel(taxInfo);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/tax")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var taxInfo = new TaxInfo
|
||||
{
|
||||
TaxIdNumber = model.TaxId,
|
||||
BillingAddressLine1 = model.Line1,
|
||||
BillingAddressLine2 = model.Line2,
|
||||
BillingAddressCity = model.City,
|
||||
BillingAddressState = model.State,
|
||||
BillingAddressPostalCode = model.PostalCode,
|
||||
BillingAddressCountry = model.Country,
|
||||
};
|
||||
await paymentService.SaveTaxInfoAsync(organization, taxInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to grant owner access to the Secrets Manager for the organization
|
||||
/// </summary>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
@ -9,7 +8,6 @@ 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;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Bit.Core.Services;
|
||||
@ -34,6 +32,7 @@ public class ProviderBillingController(
|
||||
IStripeAdapter stripeAdapter,
|
||||
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
|
||||
{
|
||||
// TODO: Migrate to Query / ProviderBillingVNextController
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
@ -54,6 +53,7 @@ public class ProviderBillingController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / ProviderBillingVNextController
|
||||
[HttpGet("invoices/{invoiceId}")]
|
||||
public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId)
|
||||
{
|
||||
@ -76,51 +76,7 @@ public class ProviderBillingController(
|
||||
"text/csv");
|
||||
}
|
||||
|
||||
[HttpPut("payment-method")]
|
||||
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] UpdatePaymentMethodRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
|
||||
var taxInformation = requestBody.TaxInformation.ToDomain();
|
||||
|
||||
await providerBillingService.UpdatePaymentMethod(
|
||||
provider,
|
||||
tokenizedPaymentSource,
|
||||
taxInformation);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("payment-method/verify-bank-account")]
|
||||
public async Task<IResult> VerifyBankAccountAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] VerifyBankAccountRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM"))
|
||||
{
|
||||
return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'");
|
||||
}
|
||||
|
||||
await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / ProviderBillingVNextController
|
||||
[HttpGet("subscription")]
|
||||
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
@ -172,53 +128,4 @@ public class ProviderBillingController(
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("tax-information")]
|
||||
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(provider);
|
||||
|
||||
var response = TaxInformationResponse.From(taxInformation);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("tax-information")]
|
||||
public async Task<IResult> UpdateTaxInformationAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] TaxInformationRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (requestBody is not { Country: not null, PostalCode: not null })
|
||||
{
|
||||
return Error.BadRequest("Country and postal code are required to update your tax information.");
|
||||
}
|
||||
|
||||
var taxInformation = new TaxInformation(
|
||||
requestBody.Country,
|
||||
requestBody.PostalCode,
|
||||
requestBody.TaxId,
|
||||
requestBody.TaxIdType,
|
||||
requestBody.Line1,
|
||||
requestBody.Line2,
|
||||
requestBody.City,
|
||||
requestBody.State);
|
||||
|
||||
await subscriberService.UpdateTaxInformation(provider, taxInformation);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Premium;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
@ -17,7 +16,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
|
||||
[Authorize("Application")]
|
||||
[Route("account/billing/vnext/self-host")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
public class SelfHostedAccountBillingController(
|
||||
public class SelfHostedAccountBillingVNextController(
|
||||
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
|
||||
{
|
||||
[HttpPost("license")]
|
||||
@ -14,7 +14,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
|
||||
[Authorize("Application")]
|
||||
[Route("organizations/{organizationId:guid}/billing/vnext/self-host")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
public class SelfHostedBillingController(
|
||||
public class SelfHostedOrganizationBillingVNextController(
|
||||
IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController
|
||||
{
|
||||
[Authorize<MemberOrProviderRequirement>]
|
||||
@ -1,31 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class TaxInformationRequestBody
|
||||
{
|
||||
[Required]
|
||||
public string Country { get; set; }
|
||||
[Required]
|
||||
public string PostalCode { get; set; }
|
||||
public string TaxId { get; set; }
|
||||
public string TaxIdType { get; set; }
|
||||
public string Line1 { get; set; }
|
||||
public string Line2 { get; set; }
|
||||
public string City { get; set; }
|
||||
public string State { get; set; }
|
||||
|
||||
public TaxInformation ToDomain() => new(
|
||||
Country,
|
||||
PostalCode,
|
||||
TaxId,
|
||||
TaxIdType,
|
||||
Line1,
|
||||
Line2,
|
||||
City,
|
||||
State);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class TokenizedPaymentSourceRequestBody
|
||||
{
|
||||
[Required]
|
||||
[EnumMatches<PaymentMethodType>(
|
||||
PaymentMethodType.BankAccount,
|
||||
PaymentMethodType.Card,
|
||||
PaymentMethodType.PayPal,
|
||||
ErrorMessage = "'type' must be BankAccount, Card or PayPal")]
|
||||
public PaymentMethodType Type { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Token { get; set; }
|
||||
|
||||
public TokenizedPaymentSource ToDomain() => new(Type, Token);
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class UpdatePaymentMethodRequestBody
|
||||
{
|
||||
[Required]
|
||||
public TokenizedPaymentSourceRequestBody PaymentSource { get; set; }
|
||||
|
||||
[Required]
|
||||
public TaxInformationRequestBody TaxInformation { get; set; }
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
public class VerifyBankAccountRequestBody
|
||||
{
|
||||
[Required]
|
||||
public string DescriptorCode { get; set; }
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public class BillingPaymentResponseModel : ResponseModel
|
||||
{
|
||||
public BillingPaymentResponseModel(BillingInfo billing)
|
||||
: base("billingPayment")
|
||||
{
|
||||
Balance = billing.Balance;
|
||||
PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null;
|
||||
}
|
||||
|
||||
public decimal Balance { get; set; }
|
||||
public BillingSource PaymentSource { get; set; }
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record PaymentMethodResponse(
|
||||
decimal AccountCredit,
|
||||
PaymentSource PaymentSource,
|
||||
string SubscriptionStatus,
|
||||
TaxInformation TaxInformation)
|
||||
{
|
||||
public static PaymentMethodResponse From(PaymentMethod paymentMethod) =>
|
||||
new(
|
||||
paymentMethod.AccountCredit,
|
||||
paymentMethod.PaymentSource,
|
||||
paymentMethod.SubscriptionStatus,
|
||||
paymentMethod.TaxInformation);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record PaymentSourceResponse(
|
||||
PaymentMethodType Type,
|
||||
string Description,
|
||||
bool NeedsVerification)
|
||||
{
|
||||
public static PaymentSourceResponse From(PaymentSource paymentMethod)
|
||||
=> new(
|
||||
paymentMethod.Type,
|
||||
paymentMethod.Description,
|
||||
paymentMethod.NeedsVerification);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record TaxInformationResponse(
|
||||
string Country,
|
||||
string PostalCode,
|
||||
string TaxId,
|
||||
string Line1,
|
||||
string Line2,
|
||||
string City,
|
||||
string State)
|
||||
{
|
||||
public static TaxInformationResponse From(TaxInformation taxInformation)
|
||||
=> new(
|
||||
taxInformation.Country,
|
||||
taxInformation.PostalCode,
|
||||
taxInformation.TaxId,
|
||||
taxInformation.Line1,
|
||||
taxInformation.Line2,
|
||||
taxInformation.City,
|
||||
taxInformation.State);
|
||||
}
|
||||
@ -63,11 +63,6 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I
|
||||
return UserId;
|
||||
}
|
||||
|
||||
public bool GetPremium()
|
||||
{
|
||||
return Premium.GetValueOrDefault(false);
|
||||
}
|
||||
|
||||
public Permissions GetPermissions()
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(Permissions) ? null
|
||||
|
||||
@ -49,11 +49,9 @@ public class AuthRequest : ITableObject<Guid>
|
||||
|
||||
public bool IsExpired()
|
||||
{
|
||||
// TODO: PM-24252 - consider using TimeProvider for better mocking in tests
|
||||
return GetExpirationDate() < DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// TODO: PM-24252 - this probably belongs in a service.
|
||||
public bool IsValidForAuthentication(Guid userId,
|
||||
string password)
|
||||
{
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin;
|
||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Auth.LoginFeatures;
|
||||
|
||||
public static class LoginServiceCollectionExtensions
|
||||
{
|
||||
public static void AddLoginServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IVerifyAuthRequestCommand, VerifyAuthRequestCommand>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
|
||||
public interface IVerifyAuthRequestCommand
|
||||
{
|
||||
Task<bool> VerifyAuthRequestAsync(Guid authRequestId, string accessCode);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin;
|
||||
|
||||
public class VerifyAuthRequestCommand : IVerifyAuthRequestCommand
|
||||
{
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
|
||||
public VerifyAuthRequestCommand(IAuthRequestRepository authRequestRepository)
|
||||
{
|
||||
_authRequestRepository = authRequestRepository;
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAuthRequestAsync(Guid authRequestId, string accessCode)
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Auth.Models;
|
||||
|
||||
/// <summary>
|
||||
/// An interface representing a user entity that supports two-factor providers
|
||||
/// </summary>
|
||||
public interface ITwoFactorProvidersUser
|
||||
{
|
||||
string TwoFactorProviders { get; }
|
||||
string? TwoFactorProviders { get; }
|
||||
/// <summary>
|
||||
/// Get the two factor providers for the user. Currently it can be assumed providers are enabled
|
||||
/// if they exists in the dictionary. When two factor providers are disabled they are removed
|
||||
@ -16,7 +16,10 @@ public interface ITwoFactorProvidersUser
|
||||
/// <see cref="IOrganizationService.DisableTwoFactorProviderAsync"/>
|
||||
/// </summary>
|
||||
/// <returns>Dictionary of providers with the type enum as the key</returns>
|
||||
Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders();
|
||||
Dictionary<TwoFactorProviderType, TwoFactorProvider>? GetTwoFactorProviders();
|
||||
/// <summary>
|
||||
/// The unique `UserId` of the user entity for which there are two-factor providers configured.
|
||||
/// </summary>
|
||||
/// <returns>The unique identifier for the user</returns>
|
||||
Guid? GetUserId();
|
||||
bool GetPremium();
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Stripe;
|
||||
using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
@ -64,16 +63,6 @@ public interface ISubscriberService
|
||||
ISubscriber subscriber,
|
||||
CustomerGetOptions customerGetOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the account credit, a masked representation of the default payment source and the tax information for the
|
||||
/// provided <paramref name="subscriber"/>. This is essentially a consolidated invocation of the <see cref="GetPaymentSource"/>
|
||||
/// and <see cref="GetTaxInformation"/> methods with a response that includes the customer's <see cref="Stripe.Customer.Balance"/> as account credit in order to cut down on Stripe API calls.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve payment method for.</param>
|
||||
/// <returns>A <see cref="Models.PaymentMethod"/> containing the subscriber's account credit, payment source and tax information.</returns>
|
||||
Task<PaymentMethod> GetPaymentMethod(
|
||||
ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a masked representation of the subscriber's payment source for presentation to a client.
|
||||
/// </summary>
|
||||
@ -107,16 +96,6 @@ public interface ISubscriberService
|
||||
ISubscriber subscriber,
|
||||
SubscriptionGetOptions subscriptionGetOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the <paramref name="subscriber"/>'s tax information using their Stripe <see cref="Stripe.Customer"/>'s <see cref="Stripe.Customer.Address"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the tax information for.</param>
|
||||
/// <returns>A <see cref="TaxInformation"/> representing the <paramref name="subscriber"/>'s tax information.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<TaxInformation> GetTaxInformation(
|
||||
ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to remove a subscriber's saved payment source. If the Stripe <see cref="Stripe.Customer"/> representing the
|
||||
/// <paramref name="subscriber"/> contains a valid <b>"btCustomerId"</b> key in its <see cref="Stripe.Customer.Metadata"/> property,
|
||||
@ -147,17 +126,6 @@ public interface ISubscriberService
|
||||
ISubscriber subscriber,
|
||||
TaxInformation taxInformation);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the subscriber's pending bank account using the provided <paramref name="descriptorCode"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to verify the bank account for.</param>
|
||||
/// <param name="descriptorCode">The code attached to a deposit made to the subscriber's bank account in order to ensure they have access to it.
|
||||
/// <a href="https://docs.stripe.com/payments/ach-debit/set-up-payment">Learn more.</a></param>
|
||||
/// <returns></returns>
|
||||
Task VerifyBankAccount(
|
||||
ISubscriber subscriber,
|
||||
string descriptorCode);
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> exists in the gateway.
|
||||
/// If the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> is <see langword="null"/> or empty, returns <see langword="true"/>.
|
||||
|
||||
@ -24,7 +24,6 @@ using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
using Customer = Stripe.Customer;
|
||||
using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Implementations;
|
||||
@ -330,38 +329,6 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PaymentMethod> GetPaymentMethod(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
var customer = await GetCustomer(subscriber, new CustomerGetOptions
|
||||
{
|
||||
Expand = ["default_source", "invoice_settings.default_payment_method", "subscriptions", "tax_ids"]
|
||||
});
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
return PaymentMethod.Empty;
|
||||
}
|
||||
|
||||
var accountCredit = customer.Balance * -1 / 100M;
|
||||
|
||||
var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer);
|
||||
|
||||
var subscriptionStatus = customer.Subscriptions
|
||||
.FirstOrDefault(subscription => subscription.Id == subscriber.GatewaySubscriptionId)?
|
||||
.Status;
|
||||
|
||||
var taxInformation = GetTaxInformation(customer);
|
||||
|
||||
return new PaymentMethod(
|
||||
accountCredit,
|
||||
paymentMethod,
|
||||
subscriptionStatus,
|
||||
taxInformation);
|
||||
}
|
||||
|
||||
public async Task<PaymentSource> GetPaymentSource(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
@ -449,16 +416,6 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxInformation> GetTaxInformation(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] });
|
||||
|
||||
return GetTaxInformation(customer);
|
||||
}
|
||||
|
||||
public async Task RemovePaymentSource(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
@ -823,57 +780,6 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task VerifyBankAccount(
|
||||
ISubscriber subscriber,
|
||||
string descriptorCode)
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
|
||||
|
||||
if (string.IsNullOrEmpty(setupIntentId))
|
||||
{
|
||||
logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId,
|
||||
new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode });
|
||||
|
||||
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId);
|
||||
|
||||
await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
|
||||
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
DefaultPaymentMethod = setupIntent.PaymentMethodId
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (StripeException stripeException)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stripeException.StripeError?.Code))
|
||||
{
|
||||
var message = stripeException.StripeError.Code switch
|
||||
{
|
||||
StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => "You have exceeded the number of allowed verification attempts. Please contact support.",
|
||||
StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => "The verification code you provided does not match the one sent to your bank account. Please try again.",
|
||||
StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => "Your bank account was not verified within the required time period. Please contact support.",
|
||||
_ => BillingException.DefaultMessage
|
||||
};
|
||||
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
logger.LogError(stripeException, "An unhandled Stripe exception was thrown while verifying subscriber's ({SubscriberID}) bank account", subscriber.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsValidGatewayCustomerIdAsync(ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
@ -970,25 +876,6 @@ public class SubscriberService(
|
||||
return PaymentSource.From(setupIntent);
|
||||
}
|
||||
|
||||
private static TaxInformation GetTaxInformation(
|
||||
Customer customer)
|
||||
{
|
||||
if (customer.Address == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TaxInformation(
|
||||
customer.Address.Country,
|
||||
customer.Address.PostalCode,
|
||||
customer.TaxIds?.FirstOrDefault()?.Value,
|
||||
customer.TaxIds?.FirstOrDefault()?.Type,
|
||||
customer.Address.Line1,
|
||||
customer.Address.Line2,
|
||||
customer.Address.City,
|
||||
customer.Address.State);
|
||||
}
|
||||
|
||||
private async Task RemoveBraintreeCustomerIdAsync(
|
||||
Customer customer)
|
||||
{
|
||||
|
||||
@ -238,6 +238,7 @@ public static class FeatureFlagKeys
|
||||
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
|
||||
public const string UseChromiumImporter = "pm-23982-chromium-importer";
|
||||
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
|
||||
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
|
||||
|
||||
/* Vault Team */
|
||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||
@ -250,6 +251,7 @@ public static class FeatureFlagKeys
|
||||
public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search";
|
||||
public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders";
|
||||
public const string BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight";
|
||||
public const string MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems";
|
||||
|
||||
/* Innovation Team */
|
||||
public const string ArchiveVaultItems = "pm-19148-innovation-archive";
|
||||
|
||||
@ -200,11 +200,6 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
return Id;
|
||||
}
|
||||
|
||||
public bool GetPremium()
|
||||
{
|
||||
return Premium;
|
||||
}
|
||||
|
||||
public int GetSecurityVersion()
|
||||
{
|
||||
// If no security version is set, it is version 1. The minimum initialized version is 2.
|
||||
|
||||
@ -0,0 +1,815 @@
|
||||
<!doctype html>
|
||||
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title></title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a { padding:0; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.mj-column-per-15 { width:15% !important; max-width: 15%; }
|
||||
.mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
|
||||
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
You can now share passwords with members of {{OrganizationName}}!
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="https://vault.bitwarden.com" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
Log in
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-enterprise.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a member of <b>{{OrganizationName}}</b>:</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:48px;">
|
||||
|
||||
<img alt="Organization Icon" src="https://assets.bitwarden.com/email/v1/icon-enterprise.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">Your account is owned by {{OrganizationName}} and is subject to their security and management policies.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:48px;">
|
||||
|
||||
<img alt="Group Users Icon" src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can easily access and share passwords with your team.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/share-to-a-collection/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
|
||||
Share passwords in Bitwarden
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
|
||||
</span>
|
||||
</a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
{{#>TitleContactUsTextLayout}}
|
||||
You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault.
|
||||
Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play.
|
||||
{{/TitleContactUsTextLayout}}
|
||||
@ -0,0 +1,983 @@
|
||||
<!doctype html>
|
||||
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title></title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a { padding:0; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.mj-column-per-15 { width:15% !important; max-width: 15%; }
|
||||
.mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
.mj-column-px-120 { width:120px !important; max-width: 120px; }
|
||||
.mj-column-px-150 { width:150px !important; max-width: 150px; }
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
|
||||
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
|
||||
.moz-text-html .mj-column-px-120 { width:120px !important; max-width: 120px; }
|
||||
.moz-text-html .mj-column-px-150 { width:150px !important; max-width: 150px; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
}
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
You can now share passwords with members of {{OrganizationName}}!
|
||||
</h1></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="https://vault.bitwarden.com" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
Log in
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-family-homes.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a member of <b>{{OrganizationName}}</b>:</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:48px;">
|
||||
|
||||
<img alt="Collections Icon" src="https://assets.bitwarden.com/email/v1/icon-item-type.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can access passwords {{OrganizationName}} has shared with you.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:48px;">
|
||||
|
||||
<img alt="Group Users Icon" src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can easily share passwords with friends, family, or coworkers.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/share-to-a-collection/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
|
||||
Share passwords in Bitwarden
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
|
||||
</span>
|
||||
</a></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Download Mobile Apps Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:30px 30px 10px 30px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:0 0 15px 0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:18px;font-weight:700;line-height:32px;text-align:left;color:#1B2029;">Download Bitwarden on all devices</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:0 0 20px 0;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:15px;line-height:16px;text-align:left;color:#1B2029;">Already using the <a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">browser extension</a>?
|
||||
Download the Bitwarden mobile app from the
|
||||
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">App Store</a>
|
||||
or <a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Google Play</a>
|
||||
to quickly save logins and autofill forms on the go.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 30px 20px 30px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:560px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:middle;width:120px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-px-120 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:21.428571428571427%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:120px;">
|
||||
|
||||
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" target="_blank">
|
||||
|
||||
<img alt="Download on the App Store" src="https://assets.bitwarden.com/email/v1/App-store.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="120" height="auto">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td style="vertical-align:middle;width:150px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-px-150 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:26.785714285714285%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0 0 0 10px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:140px;">
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" target="_blank">
|
||||
|
||||
<img alt="Get it on Google Play" src="https://assets.bitwarden.com/email/v1/google-play-badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="140" height="auto">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
{{#>TitleContactUsTextLayout}}
|
||||
You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault.
|
||||
Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play.
|
||||
{{/TitleContactUsTextLayout}}
|
||||
@ -1,4 +1,12 @@
|
||||
const { BodyComponent } = require("mjml-core");
|
||||
|
||||
const BODY_TEXT_STYLES = `
|
||||
font-family="Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||
font-size="16px"
|
||||
font-weight="400"
|
||||
line-height="24px"
|
||||
`;
|
||||
|
||||
class MjBwIconRow extends BodyComponent {
|
||||
static dependencies = {
|
||||
"mj-column": ["mj-bw-icon-row"],
|
||||
@ -18,7 +26,7 @@ class MjBwIconRow extends BodyComponent {
|
||||
|
||||
static defaultAttributes = {};
|
||||
|
||||
headStyle = (breakpoint) => {
|
||||
headStyle = (breakpoint) => {
|
||||
return `
|
||||
@media only screen and (max-width:${breakpoint}) {
|
||||
.mj-bw-icon-row-text {
|
||||
@ -36,30 +44,35 @@ class MjBwIconRow extends BodyComponent {
|
||||
render() {
|
||||
const headAnchorElement =
|
||||
this.getAttribute("head-url-text") && this.getAttribute("head-url")
|
||||
? `<a href="${this.getAttribute("head-url")}" class="link">
|
||||
${this.getAttribute("head-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>`
|
||||
? `
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px" ${BODY_TEXT_STYLES}>
|
||||
<a href="${this.getAttribute("head-url")}" class="link">
|
||||
${this.getAttribute("head-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</mj-text>`
|
||||
: "";
|
||||
|
||||
const footAnchorElement =
|
||||
this.getAttribute("foot-url-text") && this.getAttribute("foot-url")
|
||||
? `<a href="${this.getAttribute("foot-url")}" class="link">
|
||||
${this.getAttribute("foot-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>`
|
||||
? `<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px" ${BODY_TEXT_STYLES}>
|
||||
<a href="${this.getAttribute("foot-url")}" class="link">
|
||||
${this.getAttribute("foot-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</mj-text>`
|
||||
: "";
|
||||
|
||||
return this.renderMJML(
|
||||
@ -76,19 +89,11 @@ class MjBwIconRow extends BodyComponent {
|
||||
/>
|
||||
</mj-column>
|
||||
<mj-column width="85%" vertical-align="top">
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
|
||||
` +
|
||||
headAnchorElement +
|
||||
`
|
||||
</mj-text>
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
|
||||
${headAnchorElement}
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px" ${BODY_TEXT_STYLES}>
|
||||
${this.getAttribute("text")}
|
||||
</mj-text>
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
|
||||
` +
|
||||
footAnchorElement +
|
||||
`
|
||||
</mj-text>
|
||||
${footAnchorElement}
|
||||
</mj-column>
|
||||
</mj-group>
|
||||
</mj-section>
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
|
||||
title="You can now share passwords with members of {{OrganizationName}}!"
|
||||
button-text="Log in"
|
||||
button-url="https://vault.bitwarden.com"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
As a member of <b>{{OrganizationName}}</b>:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-enterprise.png"
|
||||
icon-alt="Organization Icon"
|
||||
text="Your account is owned by {{OrganizationName}} and is subject to their security and management policies."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png"
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily access and share passwords with your team."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/share-to-a-collection/"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@ -0,0 +1,55 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
|
||||
title="You can now share passwords with members of {{OrganizationName}}!"
|
||||
button-text="Log in"
|
||||
button-url="https://vault.bitwarden.com"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
As a member of <b>{{OrganizationName}}</b>:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-item-type.png"
|
||||
icon-alt="Collections Icon"
|
||||
text="You can access passwords {{OrganizationName}} has shared with you."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png"
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily share passwords with friends, family, or coworkers."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/share-to-a-collection/"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Download Mobile Apps Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-include path="../components/mobile-app-download.mjml" />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@ -4,8 +4,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Billing.Tax.Responses;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.StaticStore;
|
||||
@ -44,8 +42,6 @@ public interface IPaymentService
|
||||
Task<BillingInfo> GetBillingAsync(ISubscriber subscriber);
|
||||
Task<BillingHistoryInfo> GetBillingHistoryAsync(ISubscriber subscriber);
|
||||
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
|
||||
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
|
||||
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
|
||||
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount);
|
||||
/// <summary>
|
||||
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
|
||||
@ -68,7 +64,4 @@ public interface IPaymentService
|
||||
/// <param name="organization">Organization Representation used for Inviting Organization Users</param>
|
||||
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
|
||||
Task<bool> HasSecretsManagerStandalone(InviteOrganization organization);
|
||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -60,11 +59,22 @@ public interface IUserService
|
||||
Task<bool> CheckPasswordAsync(User user, string password);
|
||||
/// <summary>
|
||||
/// Checks if the user has access to premium features, either through a personal subscription or through an organization.
|
||||
///
|
||||
/// This is the preferred way to definitively know if a user has access to premium features.
|
||||
/// </summary>
|
||||
/// <param name="user">user being acted on</param>
|
||||
/// <returns>true if they can access premium; false otherwise.</returns>
|
||||
Task<bool> CanAccessPremium(ITwoFactorProvidersUser user);
|
||||
Task<bool> HasPremiumFromOrganization(ITwoFactorProvidersUser user);
|
||||
Task<bool> CanAccessPremium(User user);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the user has inherited access to premium features through an organization.
|
||||
///
|
||||
/// This primarily serves as a means to communicate to the client when a user has inherited their premium status
|
||||
/// through an organization. Feature gating logic probably should not be behind this check.
|
||||
/// </summary>
|
||||
/// <param name="user">user being acted on</param>
|
||||
/// <returns>true if they can access premium because of organization membership; false otherwise.</returns>
|
||||
Task<bool> HasPremiumFromOrganization(User user);
|
||||
Task<string> GenerateSignInTokenAsync(User user, string purpose);
|
||||
|
||||
Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
|
||||
|
||||
@ -8,11 +8,7 @@ using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Billing.Tax.Responses;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -36,8 +32,6 @@ public class StripePaymentService : IPaymentService
|
||||
private readonly Braintree.IBraintreeGateway _btGateway;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITaxService _taxService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public StripePaymentService(
|
||||
@ -46,8 +40,6 @@ public class StripePaymentService : IPaymentService
|
||||
IStripeAdapter stripeAdapter,
|
||||
Braintree.IBraintreeGateway braintreeGateway,
|
||||
IGlobalSettings globalSettings,
|
||||
IFeatureService featureService,
|
||||
ITaxService taxService,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_transactionRepository = transactionRepository;
|
||||
@ -55,8 +47,6 @@ public class StripePaymentService : IPaymentService
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_btGateway = braintreeGateway;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_taxService = taxService;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
@ -705,133 +695,6 @@ public class StripePaymentService : IPaymentService
|
||||
return subscriptionInfo;
|
||||
}
|
||||
|
||||
public async Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber)
|
||||
{
|
||||
if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId,
|
||||
new CustomerGetOptions { Expand = ["tax_ids"] });
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var address = customer.Address;
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
|
||||
// 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))
|
||||
{
|
||||
address.Line1 = null;
|
||||
}
|
||||
|
||||
return new TaxInfo
|
||||
{
|
||||
TaxIdNumber = taxId?.Value,
|
||||
TaxIdType = taxId?.Type,
|
||||
BillingAddressLine1 = address?.Line1,
|
||||
BillingAddressLine2 = address?.Line2,
|
||||
BillingAddressCity = address?.City,
|
||||
BillingAddressState = address?.State,
|
||||
BillingAddressPostalCode = address?.PostalCode,
|
||||
BillingAddressCountry = address?.Country,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
||||
Line2 = taxInfo.BillingAddressLine2,
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState,
|
||||
PostalCode = taxInfo.BillingAddressPostalCode,
|
||||
Country = taxInfo.BillingAddressCountry,
|
||||
},
|
||||
Expand = ["tax_ids"]
|
||||
});
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
|
||||
if (taxId != null)
|
||||
{
|
||||
await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taxIdType = taxInfo.TaxIdType;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(taxIdType))
|
||||
{
|
||||
taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||
new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber });
|
||||
|
||||
if (taxInfo.TaxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
await _stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||
new TaxIdCreateOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{taxInfo.TaxIdNumber}"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
taxInfo.TaxIdNumber,
|
||||
taxInfo.BillingAddressCountry);
|
||||
throw new BadRequestException("billingInvalidTaxIdError");
|
||||
default:
|
||||
_logger.LogError(e,
|
||||
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
|
||||
taxInfo.TaxIdNumber,
|
||||
taxInfo.BillingAddressCountry,
|
||||
customer.Id);
|
||||
throw new BadRequestException("billingTaxIdCreationError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> AddSecretsManagerToSubscription(
|
||||
Organization org,
|
||||
StaticStore.Plan plan,
|
||||
@ -909,309 +772,6 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete($"Use {nameof(PreviewPremiumTaxCommand)} instead.")]
|
||||
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
|
||||
PreviewIndividualInvoiceRequestBody parameters,
|
||||
string gatewayCustomerId,
|
||||
string gatewaySubscriptionId)
|
||||
{
|
||||
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
|
||||
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
{
|
||||
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, },
|
||||
Currency = "usd",
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
Plan = premiumPlan.Seat.StripePriceId
|
||||
},
|
||||
|
||||
new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Quantity = parameters.PasswordManager.AdditionalStorage,
|
||||
Plan = premiumPlan.Storage.StripePriceId
|
||||
}
|
||||
]
|
||||
},
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
PostalCode = parameters.TaxInformation.PostalCode,
|
||||
Country = parameters.TaxInformation.Country,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId))
|
||||
{
|
||||
var taxIdType = _taxService.GetStripeTaxCode(
|
||||
options.CustomerDetails.Address.Country,
|
||||
parameters.TaxInformation.TaxId);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvalidTaxIdError");
|
||||
}
|
||||
|
||||
options.CustomerDetails.TaxIds =
|
||||
[
|
||||
new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId }
|
||||
];
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{parameters.TaxInformation.TaxId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gatewayCustomerId))
|
||||
{
|
||||
var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||
|
||||
if (gatewayCustomer.Discount != null)
|
||||
{
|
||||
options.Discounts = [new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }];
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId))
|
||||
{
|
||||
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
|
||||
|
||||
if (gatewaySubscription?.Discounts is { Count: > 0 })
|
||||
{
|
||||
options.Discounts = gatewaySubscription.Discounts.Select(x => new InvoiceDiscountOptions { Coupon = x.Coupon.Id }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (options.Discounts is { Count: > 0 })
|
||||
{
|
||||
options.Discounts = options.Discounts.DistinctBy(invoiceDiscountOptions => invoiceDiscountOptions.Coupon).ToList();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||
|
||||
var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount);
|
||||
|
||||
var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0
|
||||
? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
|
||||
: 0M;
|
||||
|
||||
var result = new PreviewInvoiceResponseModel(
|
||||
effectiveTaxRate,
|
||||
invoice.TotalExcludingTax.ToMajor() ?? 0,
|
||||
tax.ToMajor(),
|
||||
invoice.Total.ToMajor());
|
||||
return result;
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvalidTaxIdError");
|
||||
default:
|
||||
_logger.LogError(e,
|
||||
"Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvoiceError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(
|
||||
PreviewOrganizationInvoiceRequestBody parameters,
|
||||
string gatewayCustomerId,
|
||||
string gatewaySubscriptionId)
|
||||
{
|
||||
var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan);
|
||||
var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue;
|
||||
|
||||
var options = new InvoiceCreatePreviewOptions
|
||||
{
|
||||
Currency = "usd",
|
||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Quantity = parameters.PasswordManager.AdditionalStorage,
|
||||
Plan = plan.PasswordManager.StripeStoragePlanId
|
||||
}
|
||||
]
|
||||
},
|
||||
CustomerDetails = new InvoiceCustomerDetailsOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
PostalCode = parameters.TaxInformation.PostalCode,
|
||||
Country = parameters.TaxInformation.Country,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (isSponsored)
|
||||
{
|
||||
var sponsoredPlan = SponsoredPlans.Get(parameters.PasswordManager.SponsoredPlan.Value);
|
||||
options.SubscriptionDetails.Items.Add(
|
||||
new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (plan.PasswordManager.HasAdditionalSeatsOption)
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(
|
||||
new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(
|
||||
new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = plan.PasswordManager.StripePlanId }
|
||||
);
|
||||
}
|
||||
|
||||
if (plan.SupportsSecretsManager)
|
||||
{
|
||||
if (plan.SecretsManager.HasAdditionalSeatsOption)
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Quantity = parameters.SecretsManager?.Seats ?? 0,
|
||||
Plan = plan.SecretsManager.StripeSeatPlanId
|
||||
});
|
||||
}
|
||||
|
||||
if (plan.SecretsManager.HasAdditionalServiceAccountOption)
|
||||
{
|
||||
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||
{
|
||||
Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0,
|
||||
Plan = plan.SecretsManager.StripeServiceAccountPlanId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parameters.TaxInformation.TaxId))
|
||||
{
|
||||
var taxIdType = _taxService.GetStripeTaxCode(
|
||||
options.CustomerDetails.Address.Country,
|
||||
parameters.TaxInformation.TaxId);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
|
||||
options.CustomerDetails.TaxIds =
|
||||
[
|
||||
new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId }
|
||||
];
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{parameters.TaxInformation.TaxId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Customer gatewayCustomer = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gatewayCustomerId))
|
||||
{
|
||||
gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||
|
||||
if (gatewayCustomer.Discount != null)
|
||||
{
|
||||
options.Discounts =
|
||||
[
|
||||
new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId))
|
||||
{
|
||||
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
|
||||
|
||||
if (gatewaySubscription?.Discounts != null)
|
||||
{
|
||||
options.Discounts = gatewaySubscription.Discounts
|
||||
.Select(discount => new InvoiceDiscountOptions { Coupon = discount.Coupon.Id }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||
if (parameters.PasswordManager.Plan.IsBusinessProductTierType() &&
|
||||
parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates)
|
||||
{
|
||||
options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||
|
||||
var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount);
|
||||
|
||||
var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0
|
||||
? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
|
||||
: 0M;
|
||||
|
||||
var result = new PreviewInvoiceResponseModel(
|
||||
effectiveTaxRate,
|
||||
invoice.TotalExcludingTax.ToMajor() ?? 0,
|
||||
tax.ToMajor(),
|
||||
invoice.Total.ToMajor());
|
||||
return result;
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
switch (e.StripeError.Code)
|
||||
{
|
||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||
_logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvalidTaxIdError");
|
||||
default:
|
||||
_logger.LogError(e,
|
||||
"Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
|
||||
parameters.TaxInformation.TaxId,
|
||||
parameters.TaxInformation.Country);
|
||||
throw new BadRequestException("billingPreviewInvoiceError");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PaymentMethod GetLatestCardPaymentMethod(string customerId)
|
||||
{
|
||||
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(
|
||||
|
||||
@ -1104,7 +1104,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
return success;
|
||||
}
|
||||
|
||||
public async Task<bool> CanAccessPremium(ITwoFactorProvidersUser user)
|
||||
public async Task<bool> CanAccessPremium(User user)
|
||||
{
|
||||
var userId = user.GetUserId();
|
||||
if (!userId.HasValue)
|
||||
@ -1112,10 +1112,10 @@ public class UserService : UserManager<User>, IUserService
|
||||
return false;
|
||||
}
|
||||
|
||||
return user.GetPremium() || await this.HasPremiumFromOrganization(user);
|
||||
return user.Premium || await HasPremiumFromOrganization(user);
|
||||
}
|
||||
|
||||
public async Task<bool> HasPremiumFromOrganization(ITwoFactorProvidersUser user)
|
||||
public async Task<bool> HasPremiumFromOrganization(User user)
|
||||
{
|
||||
var userId = user.GetUserId();
|
||||
if (!userId.HasValue)
|
||||
@ -1138,6 +1138,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
orgAbility.UsersGetPremium &&
|
||||
orgAbility.Enabled);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateSignInTokenAsync(User user, string purpose)
|
||||
{
|
||||
var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider,
|
||||
|
||||
@ -21,7 +21,6 @@ using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Auth.IdentityServer;
|
||||
using Bit.Core.Auth.LoginFeatures;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.Services;
|
||||
@ -140,7 +139,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IAuthRequestService, AuthRequestService>();
|
||||
services.AddScoped<IDuoUniversalTokenService, DuoUniversalTokenService>();
|
||||
services.AddScoped<ISendAuthorizationService, SendAuthorizationService>();
|
||||
services.AddLoginServices();
|
||||
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
|
||||
services.AddVaultServices();
|
||||
services.AddReportingServices();
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -11,8 +10,6 @@ using Bit.Core.Billing.Pricing;
|
||||
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;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
@ -521,49 +518,4 @@ public class ProviderBillingControllerTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateTaxInformationAsync
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateTaxInformation_NoCountry_BadRequest(
|
||||
Provider provider,
|
||||
TaxInformationRequestBody requestBody,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
ConfigureStableProviderAdminInputs(provider, sutProvider);
|
||||
|
||||
requestBody.Country = null;
|
||||
|
||||
var result = await sutProvider.Sut.UpdateTaxInformationAsync(provider.Id, requestBody);
|
||||
|
||||
Assert.IsType<BadRequest<ErrorResponseModel>>(result);
|
||||
|
||||
var response = (BadRequest<ErrorResponseModel>)result;
|
||||
|
||||
Assert.Equal("Country and postal code are required to update your tax information.", response.Value.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateTaxInformation_Ok(
|
||||
Provider provider,
|
||||
TaxInformationRequestBody requestBody,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
ConfigureStableProviderAdminInputs(provider, sutProvider);
|
||||
|
||||
await sutProvider.Sut.UpdateTaxInformationAsync(provider.Id, requestBody);
|
||||
|
||||
await sutProvider.GetDependency<ISubscriberService>().Received(1).UpdateTaxInformation(
|
||||
provider, Arg.Is<TaxInformation>(
|
||||
options =>
|
||||
options.Country == requestBody.Country &&
|
||||
options.PostalCode == requestBody.PostalCode &&
|
||||
options.TaxId == requestBody.TaxId &&
|
||||
options.Line1 == requestBody.Line1 &&
|
||||
options.Line2 == requestBody.Line2 &&
|
||||
options.City == requestBody.City &&
|
||||
options.State == requestBody.State));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="Rnwood.SmtpServer" Version="3.1.0-ci0868" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
|
||||
224
test/Core.Test/Auth/Entities/AuthRequestTests.cs
Normal file
224
test/Core.Test/Auth/Entities/AuthRequestTests.cs
Normal file
@ -0,0 +1,224 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.Entities;
|
||||
|
||||
public class AuthRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithValidRequest_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithWrongUserId_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var differentUserId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(differentUserId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Auth request should not validate for a different user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithWrongAccessCode_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = "correct-code"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, "wrong-code");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithoutResponseDate_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = null, // Not responded to
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Unanswered auth requests should not be valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithApprovedFalse_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = false, // Denied
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Denied auth requests should not be valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithApprovedNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = null, // Pending
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Pending auth requests should not be valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithExpiredRequest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow.AddMinutes(-20), // Expired (15 min timeout)
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Expired auth requests should not be valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithWrongType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.Unlock, // Wrong type
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = null,
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Only AuthenticateAndUnlock type should be valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForAuthentication_WithAlreadyUsed_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var accessCode = "test-access-code";
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
ResponseDate = DateTime.UtcNow,
|
||||
Approved = true,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
AuthenticationDate = DateTime.UtcNow, // Already used
|
||||
AccessCode = accessCode
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = authRequest.IsValidForAuthentication(userId, accessCode);
|
||||
|
||||
// Assert
|
||||
Assert.False(result, "Auth requests should only be valid for one-time use");
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
@ -356,9 +357,18 @@ public class InvoiceExtensionsTests
|
||||
[Fact]
|
||||
public void FormatForProvider_ComplexScenario_HandlesAllLineTypes()
|
||||
{
|
||||
// Arrange
|
||||
var lineItems = new StripeList<InvoiceLineItem>();
|
||||
lineItems.Data = new List<InvoiceLineItem>
|
||||
// Set culture to en-US to ensure consistent decimal formatting in tests
|
||||
// This ensures tests pass on all machines regardless of system locale
|
||||
var originalCulture = Thread.CurrentThread.CurrentCulture;
|
||||
var originalUICulture = Thread.CurrentThread.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
|
||||
Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
|
||||
|
||||
// Arrange
|
||||
var lineItems = new StripeList<InvoiceLineItem>();
|
||||
lineItems.Data = new List<InvoiceLineItem>
|
||||
{
|
||||
new InvoiceLineItem
|
||||
{
|
||||
@ -372,23 +382,29 @@ public class InvoiceExtensionsTests
|
||||
new InvoiceLineItem { Description = "Custom Service", Quantity = 2, Amount = 2000 }
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Lines = lineItems,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax
|
||||
};
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Count);
|
||||
Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]);
|
||||
Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]);
|
||||
Assert.Equal("1 × Tax (at $8.00 / month)", result[2]);
|
||||
Assert.Equal("Custom Service", result[3]);
|
||||
Assert.Equal("1 × Tax (at $2.00 / month)", result[4]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lines = lineItems,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax
|
||||
};
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Count);
|
||||
Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]);
|
||||
Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]);
|
||||
Assert.Equal("1 × Tax (at $8.00 / month)", result[2]);
|
||||
Assert.Equal("Custom Service", result[3]);
|
||||
Assert.Equal("1 × Tax (at $2.00 / month)", result[4]);
|
||||
Thread.CurrentThread.CurrentCulture = originalCulture;
|
||||
Thread.CurrentThread.CurrentUICulture = originalUICulture;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@ -328,157 +328,6 @@ public class SubscriberServiceTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPaymentMethod
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethod_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.GetPaymentSource(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethod_WithNegativeStripeAccountBalance_ReturnsCorrectAccountCreditAmount(Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
// Stripe reports balance in cents as a negative number for credit
|
||||
const int stripeAccountBalance = -593; // $5.93 credit (negative cents)
|
||||
const decimal creditAmount = 5.93M; // Same value in dollars
|
||||
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Balance = stripeAccountBalance,
|
||||
Subscriptions = new StripeList<Subscription>()
|
||||
{
|
||||
Data =
|
||||
[new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }]
|
||||
},
|
||||
InvoiceSettings = new CustomerInvoiceSettings
|
||||
{
|
||||
DefaultPaymentMethod = new PaymentMethod
|
||||
{
|
||||
Type = StripeConstants.PaymentMethodTypes.USBankAccount,
|
||||
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
|
||||
}
|
||||
}
|
||||
};
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerGetOptions>(options => options.Expand.Contains("default_source") &&
|
||||
options.Expand.Contains("invoice_settings.default_payment_method")
|
||||
&& options.Expand.Contains("subscriptions")
|
||||
&& options.Expand.Contains("tax_ids")))
|
||||
.Returns(customer);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetPaymentMethod(organization);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(creditAmount, result.AccountCredit);
|
||||
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerGetAsync(
|
||||
organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerGetOptions>(options =>
|
||||
options.Expand.Contains("default_source") &&
|
||||
options.Expand.Contains("invoice_settings.default_payment_method") &&
|
||||
options.Expand.Contains("subscriptions") &&
|
||||
options.Expand.Contains("tax_ids")));
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethod_WithZeroStripeAccountBalance_ReturnsCorrectAccountCreditAmount(
|
||||
Organization organization, SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const int stripeAccountBalance = 0;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Balance = stripeAccountBalance,
|
||||
Subscriptions = new StripeList<Subscription>()
|
||||
{
|
||||
Data =
|
||||
[new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }]
|
||||
},
|
||||
InvoiceSettings = new CustomerInvoiceSettings
|
||||
{
|
||||
DefaultPaymentMethod = new PaymentMethod
|
||||
{
|
||||
Type = StripeConstants.PaymentMethodTypes.USBankAccount,
|
||||
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
|
||||
}
|
||||
}
|
||||
};
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerGetOptions>(options => options.Expand.Contains("default_source") &&
|
||||
options.Expand.Contains("invoice_settings.default_payment_method")
|
||||
&& options.Expand.Contains("subscriptions")
|
||||
&& options.Expand.Contains("tax_ids")))
|
||||
.Returns(customer);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetPaymentMethod(organization);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.AccountCredit);
|
||||
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerGetAsync(
|
||||
organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerGetOptions>(options =>
|
||||
options.Expand.Contains("default_source") &&
|
||||
options.Expand.Contains("invoice_settings.default_payment_method") &&
|
||||
options.Expand.Contains("subscriptions") &&
|
||||
options.Expand.Contains("tax_ids")));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPaymentMethod_WithPositiveStripeAccountBalance_ReturnsCorrectAccountCreditAmount(
|
||||
Organization organization, SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const int stripeAccountBalance = 593; // $5.93 charge balance
|
||||
const decimal accountBalance = -5.93M; // account balance
|
||||
var customer = new Customer
|
||||
{
|
||||
Balance = stripeAccountBalance,
|
||||
Subscriptions = new StripeList<Subscription>()
|
||||
{
|
||||
Data =
|
||||
[new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }]
|
||||
},
|
||||
InvoiceSettings = new CustomerInvoiceSettings
|
||||
{
|
||||
DefaultPaymentMethod = new PaymentMethod
|
||||
{
|
||||
Type = StripeConstants.PaymentMethodTypes.USBankAccount,
|
||||
UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" }
|
||||
}
|
||||
}
|
||||
};
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerGetOptions>(options => options.Expand.Contains("default_source") &&
|
||||
options.Expand.Contains("invoice_settings.default_payment_method")
|
||||
&& options.Expand.Contains("subscriptions")
|
||||
&& options.Expand.Contains("tax_ids")))
|
||||
.Returns(customer);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetPaymentMethod(organization);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(accountBalance, result.AccountCredit);
|
||||
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerGetAsync(
|
||||
organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerGetOptions>(options =>
|
||||
options.Expand.Contains("default_source") &&
|
||||
options.Expand.Contains("invoice_settings.default_payment_method") &&
|
||||
options.Expand.Contains("subscriptions") &&
|
||||
options.Expand.Contains("tax_ids")));
|
||||
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetPaymentSource
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@ -889,65 +738,6 @@ public class SubscriberServiceTests
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GetTaxInformation
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformation_NullSubscriber_ThrowsArgumentNullException(
|
||||
SutProvider<SubscriberService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.GetTaxInformation(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformation_NullAddress_ReturnsNull(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(new Customer());
|
||||
|
||||
var taxInformation = await sutProvider.Sut.GetTaxInformation(organization);
|
||||
|
||||
Assert.Null(taxInformation);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetTaxInformation_Success(
|
||||
Organization organization,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
var address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345",
|
||||
Line1 = "123 Example St.",
|
||||
Line2 = "Unit 1",
|
||||
City = "Example Town",
|
||||
State = "NY"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(new Customer
|
||||
{
|
||||
Address = address,
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = [new TaxId { Value = "tax_id" }]
|
||||
}
|
||||
});
|
||||
|
||||
var taxInformation = await sutProvider.Sut.GetTaxInformation(organization);
|
||||
|
||||
Assert.NotNull(taxInformation);
|
||||
Assert.Equal(address.Country, taxInformation.Country);
|
||||
Assert.Equal(address.PostalCode, taxInformation.PostalCode);
|
||||
Assert.Equal("tax_id", taxInformation.TaxId);
|
||||
Assert.Equal(address.Line1, taxInformation.Line1);
|
||||
Assert.Equal(address.Line2, taxInformation.Line2);
|
||||
Assert.Equal(address.City, taxInformation.City);
|
||||
Assert.Equal(address.State, taxInformation.State);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemovePaymentMethod
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NullSubscriber_ThrowsArgumentNullException(
|
||||
@ -1844,48 +1634,6 @@ public class SubscriberServiceTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region VerifyBankAccount
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task VerifyBankAccount_NoSetupIntentId_ThrowsBillingException(
|
||||
Provider provider,
|
||||
SutProvider<SubscriberService> sutProvider) => await ThrowsBillingExceptionAsync(() => sutProvider.Sut.VerifyBankAccount(provider, ""));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task VerifyBankAccount_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
SutProvider<SubscriberService> sutProvider)
|
||||
{
|
||||
const string descriptorCode = "SM1234";
|
||||
|
||||
var setupIntent = new SetupIntent
|
||||
{
|
||||
Id = "setup_intent_id",
|
||||
PaymentMethodId = "payment_method_id"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter.SetupIntentGet(setupIntent.Id).Returns(setupIntent);
|
||||
|
||||
await sutProvider.Sut.VerifyBankAccount(provider, descriptorCode);
|
||||
|
||||
await stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id,
|
||||
Arg.Is<SetupIntentVerifyMicrodepositsOptions>(
|
||||
options => options.DescriptorCode == descriptorCode));
|
||||
|
||||
await stripeAdapter.Received(1).PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
|
||||
Arg.Is<PaymentMethodAttachOptions>(
|
||||
options => options.Customer == provider.GatewayCustomerId));
|
||||
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||
options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsValidGatewayCustomerIdAsync
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.Billing.Mocks.Plans;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
@ -17,506 +13,6 @@ namespace Bit.Core.Test.Services;
|
||||
[SutProviderCustomize]
|
||||
public class StripePaymentServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager =
|
||||
new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(p =>
|
||||
p.Currency == "usd" &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripePlanId &&
|
||||
x.Quantity == 1) &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 0)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 4000,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 4800
|
||||
});
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
Assert.Equal(8M, actual.TaxAmount);
|
||||
Assert.Equal(48M, actual.TotalAmount);
|
||||
Assert.Equal(40M, actual.TaxableBaseAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager =
|
||||
new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(p =>
|
||||
p.Currency == "usd" &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripePlanId &&
|
||||
x.Quantity == 1) &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 1)))
|
||||
.Returns(new Invoice { TotalExcludingTax = 4000, TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 4800 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
Assert.Equal(8M, actual.TaxAmount);
|
||||
Assert.Equal(48M, actual.TotalAmount);
|
||||
Assert.Equal(40M, actual.TaxableBaseAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(p =>
|
||||
p.Currency == "usd" &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == "2021-family-for-enterprise-annually" &&
|
||||
x.Quantity == 1) &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 0)))
|
||||
.Returns(new Invoice { TotalExcludingTax = 0, TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 0 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
Assert.Equal(0M, actual.TaxAmount);
|
||||
Assert.Equal(0M, actual.TotalAmount);
|
||||
Assert.Equal(0M, actual.TaxableBaseAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(p =>
|
||||
p.Currency == "usd" &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == "2021-family-for-enterprise-annually" &&
|
||||
x.Quantity == 1) &&
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 1)))
|
||||
.Returns(new Invoice { TotalExcludingTax = 400, TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
Assert.Equal(0.08M, actual.TaxAmount);
|
||||
Assert.Equal(4.08M, actual.TotalAmount);
|
||||
Assert.Equal(4M, actual.TaxableBaseAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_PersonalUse_DoesNotSetTaxExempt(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_BusinessUse_DoesNotSetTaxExempt(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_DoesNotSetTaxExempt(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsTaxExemptReverse(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer(
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user