diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 574ac3e65..53027c304 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,5 +1,6 @@ #nullable enable using Bit.Api.Billing.Models.Responses; +using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Services; using Bit.Core.Services; using Bit.Core.Utilities; @@ -77,4 +78,12 @@ public class AccountsBillingController( return TypedResults.Ok(transactions); } + + [HttpPost("preview-invoice"), AllowAnonymous] + public async Task PreviewInvoiceAsync([FromBody] PreviewInvoiceRequestBody model) + { + var invoice = await paymentService.PreviewInvoiceAsync(model); + + return TypedResults.Ok(invoice); + } } diff --git a/src/Core/Billing/Extensions/CurrencyExtensions.cs b/src/Core/Billing/Extensions/CurrencyExtensions.cs new file mode 100644 index 000000000..cde1a7bea --- /dev/null +++ b/src/Core/Billing/Extensions/CurrencyExtensions.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.Billing.Extensions; + +public static class CurrencyExtensions +{ + /// + /// Converts a currency amount in major units to minor units. + /// + /// 123.99 USD returns 12399 in minor units. + public static long ToMinor(this decimal amount) + { + return Convert.ToInt64(amount * 100); + } + + /// + /// Converts a currency amount in minor units to major units. + /// + /// + /// 12399 in minor units returns 123.99 USD. + public static decimal? ToMajor(this long? amount) + { + return amount?.ToMajor(); + } + + /// + /// Converts a currency amount in minor units to major units. + /// + /// + /// 12399 in minor units returns 123.99 USD. + public static decimal ToMajor(this long amount) + { + return Convert.ToDecimal(amount) / 100; + } +} diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewInvoiceRequestModel.cs new file mode 100644 index 000000000..5717bd459 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewInvoiceRequestModel.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Billing.Models.Api.Requests.Accounts; + +public class PreviewInvoiceRequestBody +{ + [Required] + public PasswordManagerRequestModel PasswordManager { get; set; } + + [Required] + public TaxInformationRequestModel TaxInformation { get; set; } +} + +public class PasswordManagerRequestModel +{ + [Range(0, int.MaxValue)] + public int AdditionalStorage { get; set; } +} diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs new file mode 100644 index 000000000..b0971e73d --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Billing.Models.Api.Requests; + +public class TaxInformationRequestModel +{ + [Length(2, 2), Required] + public string Country { get; set; } + + [Required] + public string PostalCode { get; set; } + + public string? TaxId { get; set; } +} diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs new file mode 100644 index 000000000..fdde7dae1 --- /dev/null +++ b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Models.Api.Responses; + +public record PreviewInvoiceResponseModel( + decimal EffectiveTaxRate, + decimal TaxableBaseAmount, + decimal TaxAmount, + decimal TotalAmount); diff --git a/src/Core/Billing/Models/PreviewInvoiceInfo.cs b/src/Core/Billing/Models/PreviewInvoiceInfo.cs new file mode 100644 index 000000000..16a2019c2 --- /dev/null +++ b/src/Core/Billing/Models/PreviewInvoiceInfo.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Models; + +public record PreviewInvoiceInfo( + decimal EffectiveTaxRate, + decimal TaxableBaseAmount, + decimal TaxAmount, + decimal TotalAmount); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index bf9d04702..9fe4b8355 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,6 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -59,4 +61,5 @@ public interface IPaymentService Task RisksSubscriptionFailure(Organization organization); Task HasSecretsManagerStandalone(Organization organization); Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription); + Task PreviewInvoiceAsync(PreviewInvoiceRequestBody parameters); } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 30583ef0b..ef2e3ab76 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -31,6 +31,7 @@ public interface IStripeAdapter Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options); Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options); Task> InvoiceListAsync(StripeInvoiceListOptions options); + Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options); Task> InvoiceSearchAsync(InvoiceSearchOptions options); Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options); Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options); @@ -42,6 +43,7 @@ public interface IStripeAdapter IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); + Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null); Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options); Task TaxRateUpdateAsync(string id, Stripe.TaxRateUpdateOptions options); Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 8d1833145..f4f8efe75 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -15,6 +15,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.RefundService _refundService; private readonly Stripe.CardService _cardService; private readonly Stripe.BankAccountService _bankAccountService; + private readonly Stripe.PlanService _planService; private readonly Stripe.PriceService _priceService; private readonly Stripe.SetupIntentService _setupIntentService; private readonly Stripe.TestHelpers.TestClockService _testClockService; @@ -33,6 +34,7 @@ public class StripeAdapter : IStripeAdapter _cardService = new Stripe.CardService(); _bankAccountService = new Stripe.BankAccountService(); _priceService = new Stripe.PriceService(); + _planService = new Stripe.PlanService(); _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); @@ -133,6 +135,11 @@ public class StripeAdapter : IStripeAdapter return invoices; } + public Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options) + { + return _invoiceService.CreatePreviewAsync(options); + } + public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) => (await _invoiceService.SearchAsync(options)).Data; @@ -184,6 +191,11 @@ public class StripeAdapter : IStripeAdapter return _paymentMethodService.DetachAsync(id, options); } + public Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null) + { + return _planService.GetAsync(id, options); + } + public Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options) { return _taxRateService.CreateAsync(options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 1f84e1b0e..d6ce522e3 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,7 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -1879,6 +1882,86 @@ public class StripePaymentService : IPaymentService } } + public async Task PreviewInvoiceAsync(PreviewInvoiceRequestBody parameters) + { + var pmStripePlan = await _stripeAdapter.PlanGetAsync("premium-annually"); + var storageStripePlan = await _stripeAdapter.PlanGetAsync("storage-gb-annually"); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = true, + }, + Currency = "usd", + InvoiceItems = new List + { + new() + { + Quantity = 1, + PriceData = new InvoiceItemPriceDataOptions + { + Currency = "usd", + UnitAmount = pmStripePlan.Amount, + Product = pmStripePlan.ProductId + } + }, + new() + { + Quantity = parameters.PasswordManager.AdditionalStorage, + PriceData = new InvoiceItemPriceDataOptions + { + Currency = "usd", + UnitAmount = storageStripePlan.Amount, + Product = storageStripePlan.ProductId + } + } + }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + PostalCode = parameters.TaxInformation.PostalCode, + Country = parameters.TaxInformation.Country, + } + }, + }; + + if (parameters.TaxInformation.TaxId != null) + { + var taxIdType = _taxService.GetStripeTaxCode( + options.CustomerDetails.Address.Country, + parameters.TaxInformation.TaxId); + + if (taxIdType != null) + { + options.CustomerDetails.TaxIds = [ + new InvoiceCustomerDetailsTaxIdOptions + { + Type = taxIdType, + Value = parameters.TaxInformation.TaxId + } + ]; + } + } + + var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); + + decimal effectiveTaxRate = 0; + + if (invoice.Tax != null && invoice.TotalExcludingTax != null) + { + effectiveTaxRate = invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor(); + } + + var result = new PreviewInvoiceResponseModel( + effectiveTaxRate, + invoice.TotalExcludingTax.ToMajor() ?? 0, + invoice.Tax.ToMajor() ?? 0, + invoice.Total.ToMajor()); + return result; + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(