[PM-29224] Remove unused billing endpoints and code paths (#6692)

* Remove unused endpoints and code paths

* MOAR DELETE

* Run dotnet format
This commit is contained in:
Alex Morask 2025-12-09 08:46:15 -06:00 committed by GitHub
parent 3e12cfc6df
commit 579d8004ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 28 additions and 2056 deletions

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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(

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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")]

View File

@ -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>]

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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"/>.

View File

@ -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)
{

View File

@ -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);
}

View File

@ -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(

View File

@ -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
}

View File

@ -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]

View File

@ -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(