diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs new file mode 100644 index 000000000..b3f261c62 --- /dev/null +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -0,0 +1,59 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Context; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Route("invoices")] +[Authorize("Application")] +public class InvoicesController : BaseBillingController +{ + [HttpPost("preview-organization")] + public async Task PreviewInvoiceAsync( + [FromBody] PreviewOrganizationInvoiceRequestBody model, + [FromServices] ICurrentContext currentContext, + [FromServices] IOrganizationRepository organizationRepository, + [FromServices] IPaymentService paymentService) + { + Organization? organization = null; + if (model.OrganizationId.HasValue) + { + if (!await currentContext.EditPaymentMethods(model.OrganizationId.Value)) + { + return Error.Unauthorized(); + } + organization = await organizationRepository.GetByIdAsync(model.OrganizationId.Value); + if (organization == null) + { + return Error.NotFound(); + } + } + + var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId, organization?.GatewaySubscriptionId); + + return TypedResults.Ok(invoice); + } + + + + + + + + + + + + + + + + + + + +} diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 510cf670f..75ae2fb89 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -311,16 +311,17 @@ public class OrganizationsController( throw new NotFoundException(); } - var taxInfo = new TaxInformation( - model.Country, - model.PostalCode, - model.TaxId, - model.TaxIdType, - model.Line1, - model.Line2, - model.City, - model.State); - await subscriberService.UpdateTaxInformation(organization, taxInfo); + 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); } /// diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs new file mode 100644 index 000000000..4ffd169c3 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Models.Api.Requests.Organizations; + +public class PreviewOrganizationInvoiceRequestBody +{ + public Guid? OrganizationId { get; set; } + + [Required] + public PasswordManagerRequestModel PasswordManager { get; set; } + + public SecretsManagerRequestModel? SecretsManager { get; set; } + + [Required] + public TaxInformationRequestModel TaxInformation { get; set; } +} + +public class PasswordManagerRequestModel +{ + public PlanType Plan { get; set; } + + [Range(0, int.MaxValue)] + public int Seats { get; set; } + + [Range(0, int.MaxValue)] + public int AdditionalStorage { get; set; } +} + +public class SecretsManagerRequestModel +{ + [Range(0, int.MaxValue)] + public int Seats { get; set; } + + [Range(0, int.MaxValue)] + public int AdditionalMachineAccounts { get; set; } +} diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 328dba097..7d0f9d3c6 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Requests.Organizations; using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Entities; using Bit.Core.Enums; @@ -62,4 +63,6 @@ public interface IPaymentService Task HasSecretsManagerStandalone(Organization organization); Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription); Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); + Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); + } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 52c7ca8a8..33a957e0d 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,9 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Requests.Organizations; using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; @@ -2001,6 +2003,161 @@ public class StripePaymentService : IPaymentService } } + public async Task PreviewInvoiceAsync( + PreviewOrganizationInvoiceRequestBody parameters, + string gatewayCustomerId, + string gatewaySubscriptionId) + { + var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = true, + }, + Currency = "usd", + Discounts = new List(), + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new() + { + Quantity = parameters.PasswordManager.AdditionalStorage, + Plan = plan.PasswordManager.StripeStoragePlanId + } + ] + }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + PostalCode = parameters.TaxInformation.PostalCode, + Country = parameters.TaxInformation.Country, + } + }, + }; + + switch (plan.ProductTier) + { + case ProductTierType.Families: + options.SubscriptionDetails.Items.Add( + new() + { + Quantity = 1, + Plan = plan.PasswordManager.StripePlanId + } + ); + break; + case ProductTierType.Teams: + case ProductTierType.TeamsStarter: + case ProductTierType.Enterprise: + options.SubscriptionDetails.Items.Add( + new() + { + Quantity = parameters.PasswordManager.Seats, + Plan = plan.PasswordManager.StripeSeatPlanId + } + ); + break; + } + + if (plan.SupportsSecretsManager) + { + options.SubscriptionDetails.Items.AddRange([ + new() + { + Quantity = parameters.SecretsManager?.Seats ?? 0, + Plan = plan.SecretsManager.StripeSeatPlanId + }, + new() + { + Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, + Plan = plan.SecretsManager.StripeServiceAccountPlanId + } + ]); + } + + if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) + { + var taxIdType = _taxService.GetStripeTaxCode( + options.CustomerDetails.Address.Country, + parameters.TaxInformation.TaxId); + + if (taxIdType == null) + { + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvalidTaxIdError"); + } + + options.CustomerDetails.TaxIds = [ + new InvoiceCustomerDetailsTaxIdOptions + { + Type = taxIdType, + Value = parameters.TaxInformation.TaxId + } + ]; + } + + if (gatewayCustomerId != null) + { + var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + + if (gatewayCustomer.Discount != null) + { + options.Discounts.Add(new InvoiceDiscountOptions + { + Discount = gatewayCustomer.Discount.Id + }); + } + + var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); + + if (gatewaySubscription?.Discount != null) + { + options.Discounts.Add(new InvoiceDiscountOptions + { + Discount = gatewaySubscription.Discount.Id + }); + } + } + + try + { + var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); + + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() + : 0M; + + var result = new PreviewInvoiceResponseModel( + effectiveTaxRate, + invoice.TotalExcludingTax.ToMajor() ?? 0, + invoice.Tax.ToMajor() ?? 0, + invoice.Total.ToMajor()); + return result; + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvalidTaxIdError"); + default: + _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvoiceError"); + } + } + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(