diff --git a/src/Api/Billing/Controllers/PreviewInvoiceController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs index c95845461806..afb45e2f0e0e 100644 --- a/src/Api/Billing/Controllers/PreviewInvoiceController.cs +++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs @@ -52,8 +52,8 @@ public async Task PreviewOrganizationSubscriptionUpdateTaxAsync( public async Task PreviewPremiumSubscriptionPurchaseTaxAsync( [FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request) { - var (purchase, billingAddress) = request.ToDomain(); - var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); + var (preview, billingAddress) = request.ToDomain(); + var result = await previewPremiumTaxCommand.Run(preview, billingAddress); return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 579804df0f04..444dc857464f 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -80,9 +80,8 @@ public async Task CreateSubscriptionAsync( [BindNever] User user, [FromBody] PremiumCloudHostedSubscriptionRequest request) { - var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain(); - var result = await createPremiumCloudHostedSubscriptionCommand.Run( - user, paymentMethod, billingAddress, additionalStorageGb); + var subscriptionPurchase = request.ToDomain(); + var result = await createPremiumCloudHostedSubscriptionCommand.Run(user, subscriptionPurchase); return Handle(result); } diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs index c678b1966c92..71bc7cc860d3 100644 --- a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -20,6 +20,9 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject public SecretsManagerPurchaseSelections? SecretsManager { get; set; } + [MaxLength(50)] + public string? Coupon { get; set; } + public OrganizationSubscriptionPurchase ToDomain() => new() { Tier = Tier, @@ -35,7 +38,8 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject Seats = SecretsManager.Seats, AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, Standalone = SecretsManager.Standalone - } : null + } : null, + Coupon = Coupon }; public IEnumerable Validate(ValidationContext validationContext) diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index 0f9198fdad17..8978b06242c6 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; namespace Bit.Api.Billing.Models.Requests.Premium; @@ -15,8 +16,10 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject [Range(0, 99)] public short AdditionalStorageGb { get; set; } = 0; + [MaxLength(50)] + public string? Coupon { get; set; } - public (PaymentMethod, BillingAddress, short) ToDomain() + public PremiumSubscriptionPurchase ToDomain() { // Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided. var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain(); @@ -28,7 +31,13 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject var billingAddress = BillingAddress.ToDomain(); - return (paymentMethod, billingAddress, AdditionalStorageGb); + return new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = AdditionalStorageGb, + Coupon = Coupon + }; } public IEnumerable Validate(ValidationContext validationContext) diff --git a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs index d1707cf6de6c..a5fdaea64de0 100644 --- a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; @@ -13,5 +14,14 @@ public record PreviewPremiumSubscriptionPurchaseTaxRequest [Required] public required MinimalBillingAddressRequest BillingAddress { get; set; } - public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain()); + [MaxLength(50)] + public string? Coupon { get; set; } + + public (PremiumPurchasePreview, BillingAddress) ToDomain() => ( + new PremiumPurchasePreview + { + AdditionalStorageGb = AdditionalStorage, + Coupon = Coupon + }, + BillingAddress.ToDomain()); } diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index ddf3479aa36f..a3b9b0a41d59 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ namespace Bit.Core.Billing.Extensions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; public static class ServiceCollectionExtensions { @@ -31,6 +32,7 @@ public static void AddBillingOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.TryAddTransient(); services.AddLicenseServices(); services.AddLicenseOperations(); services.AddPricingClient(); diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 2a5e786c98f8..508020c942ea 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -75,6 +75,8 @@ public class PreviewOrganizationTaxCommand( Quantity = purchase.SecretsManager.Seats } ]); + // System coupon takes precedence for standalone Secrets Manager purchases. + // Any user-provided coupons are ignored in this scenario. options.Discounts = [ new InvoiceDiscountOptions @@ -120,6 +122,11 @@ public class PreviewOrganizationTaxCommand( } } + if (!string.IsNullOrWhiteSpace(purchase.Coupon)) + { + options.Discounts = [new InvoiceDiscountOptions { Coupon = purchase.Coupon.Trim() }]; + } + break; } diff --git a/src/Core/Billing/Organizations/Models/OrganizationSale.cs b/src/Core/Billing/Organizations/Models/OrganizationSale.cs index a984d5fe7119..16479028eedb 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSale.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSale.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Tax.Models; +using Bit.Core.Entities; using Bit.Core.Models.Business; namespace Bit.Core.Billing.Organizations.Models; @@ -14,16 +15,19 @@ internal OrganizationSale() { } public void Deconstruct( out Organization organization, out CustomerSetup? customerSetup, - out SubscriptionSetup subscriptionSetup) + out SubscriptionSetup subscriptionSetup, + out User? owner) { organization = Organization; customerSetup = CustomerSetup; subscriptionSetup = SubscriptionSetup; + owner = Owner; } public required Organization Organization { get; init; } public CustomerSetup? CustomerSetup { get; init; } public required SubscriptionSetup SubscriptionSetup { get; init; } + public User? Owner { get; init; } public static OrganizationSale From( Organization organization, @@ -40,7 +44,8 @@ public static OrganizationSale From( { Organization = organization, CustomerSetup = customerSetup, - SubscriptionSetup = subscriptionSetup + SubscriptionSetup = subscriptionSetup, + Owner = signup.Owner }; } diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs index 6691d6984814..09bda1dde45f 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -8,6 +8,7 @@ public record OrganizationSubscriptionPurchase public PlanCadenceType Cadence { get; init; } public required PasswordManagerSelections PasswordManager { get; init; } public SecretsManagerSelections? SecretsManager { get; init; } + public string? Coupon { get; init; } public PlanType PlanType => // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index a1b57c24150b..cff8e87bdeb1 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -38,7 +38,7 @@ public class OrganizationBillingService( { public async Task Finalize(OrganizationSale sale) { - var (organization, customerSetup, subscriptionSetup) = sale; + var (organization, customerSetup, subscriptionSetup, owner) = sale; var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) @@ -484,7 +484,7 @@ private async Task CreateSubscriptionAsync( { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, - Discounts = !string.IsNullOrEmpty(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon }] : null, + Discounts = !string.IsNullOrWhiteSpace(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }] : null, Items = subscriptionItemOptionsList, Metadata = new Dictionary { diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 7dc90676353c..21fd1499998b 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -1,10 +1,12 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; @@ -37,15 +39,11 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand /// Creates a premium cloud-hosted subscription for the specified user. /// /// The user to create the premium subscription for. Must not yet be a premium user. - /// The tokenized payment method containing the payment type and token for billing. - /// The billing address information required for tax calculation and customer creation. - /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). + /// The subscription purchase details including payment method, billing address, storage, and optional coupon. /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, - PaymentMethod paymentMethod, - BillingAddress billingAddress, - short additionalStorageGb); + PremiumSubscriptionPurchase subscriptionPurchase); } public class CreatePremiumCloudHostedSubscriptionCommand( @@ -60,7 +58,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ILogger logger, IPricingClient pricingClient, IHasPaymentMethodQuery hasPaymentMethodQuery, - IUpdatePaymentMethodCommand updatePaymentMethodCommand) + IUpdatePaymentMethodCommand updatePaymentMethodCommand, + ISubscriptionDiscountService subscriptionDiscountService) : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand { private static readonly List _expand = ["tax"]; @@ -68,9 +67,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( public Task> Run( User user, - PaymentMethod paymentMethod, - BillingAddress billingAddress, - short additionalStorageGb) => HandleAsync(async () => + PremiumSubscriptionPurchase subscriptionPurchase) => HandleAsync(async () => { // A "terminal" subscription is one that has ended and cannot be renewed/reactivated. // These are: 'canceled' (user canceled) and 'incomplete_expired' (payment failed and time expired). @@ -83,11 +80,20 @@ public Task> Run( return new BadRequest("Already a premium user."); } - if (additionalStorageGb < 0) + if (subscriptionPurchase.AdditionalStorageGb < 0) { return new BadRequest("Additional storage must be greater than 0."); } + if (!string.IsNullOrWhiteSpace(subscriptionPurchase.Coupon)) + { + var isValid = await subscriptionDiscountService.ValidateDiscountForUserAsync(user, subscriptionPurchase.Coupon.Trim(), DiscountAudienceType.UserHasNoPreviousSubscriptions); + if (!isValid) + { + return new BadRequest("The coupon code is invalid or you are not eligible for this discount."); + } + } + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); Customer? customer; @@ -97,7 +103,7 @@ public Task> Run( */ if (string.IsNullOrEmpty(user.GatewayCustomerId)) { - customer = await CreateCustomerAsync(user, paymentMethod, billingAddress); + customer = await CreateCustomerAsync(user, subscriptionPurchase.PaymentMethod, subscriptionPurchase.BillingAddress); } /* * An existing customer without a payment method starting a new subscription indicates a user who previously @@ -108,9 +114,9 @@ public Task> Run( * Additionally, if this is a resubscribe scenario with a tokenized payment method, we should update the payment method * to ensure the new payment method is used instead of the old one. */ - else if (paymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription)) + else if (subscriptionPurchase.PaymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription)) { - await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress); + await updatePaymentMethodCommand.Run(user, subscriptionPurchase.PaymentMethod.AsTokenized, subscriptionPurchase.BillingAddress); customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); } else @@ -118,11 +124,11 @@ public Task> Run( customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); } - customer = await ReconcileBillingLocationAsync(customer, billingAddress); + customer = await ReconcileBillingLocationAsync(customer, subscriptionPurchase.BillingAddress); - var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, additionalStorageGb > 0 ? additionalStorageGb : null); + var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, subscriptionPurchase.Coupon); - paymentMethod.Switch( + subscriptionPurchase.PaymentMethod.Switch( tokenized => { // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault @@ -153,7 +159,7 @@ public Task> Run( user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; user.GatewaySubscriptionId = subscription.Id; - user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb); + user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + subscriptionPurchase.AdditionalStorageGb); user.LicenseKey = CoreHelpers.SecureRandomString(20); user.RevisionDate = DateTime.UtcNow; @@ -319,7 +325,8 @@ private async Task CreateSubscriptionAsync( Guid userId, Customer customer, Pricing.Premium.Plan premiumPlan, - int? storage) + int? storage, + string? coupon) { var subscriptionItemOptionsList = new List @@ -361,6 +368,11 @@ private async Task CreateSubscriptionAsync( OffSession = true }; + if (!string.IsNullOrWhiteSpace(coupon)) + { + subscriptionCreateOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }]; + } + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (!usingPayPal) diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 07247c83cb9e..e71b743bf481 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Microsoft.Extensions.Logging; @@ -10,7 +11,7 @@ namespace Bit.Core.Billing.Premium.Commands; public interface IPreviewPremiumTaxCommand { Task> Run( - int additionalStorage, + PremiumPurchasePreview preview, BillingAddress billingAddress); } @@ -20,7 +21,7 @@ public class PreviewPremiumTaxCommand( IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand { public Task> Run( - int additionalStorage, + PremiumPurchasePreview preview, BillingAddress billingAddress) => HandleAsync<(decimal, decimal)>(async () => { @@ -47,15 +48,20 @@ public class PreviewPremiumTaxCommand( } }; - if (additionalStorage > 0) + if (preview.AdditionalStorageGb > 0) { options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Storage.StripePriceId, - Quantity = additionalStorage + Quantity = preview.AdditionalStorageGb }); } + if (!string.IsNullOrWhiteSpace(preview.Coupon)) + { + options.Discounts = [new InvoiceDiscountOptions { Coupon = preview.Coupon.Trim() }]; + } + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); }); diff --git a/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs b/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs new file mode 100644 index 000000000000..327a8001c581 --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Premium.Models; + +public record PremiumPurchasePreview +{ + public short AdditionalStorageGb { get; init; } + public string? Coupon { get; init; } +} diff --git a/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs b/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs new file mode 100644 index 000000000000..23653720891a --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs @@ -0,0 +1,11 @@ +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Core.Billing.Premium.Models; + +public record PremiumSubscriptionPurchase +{ + public required PaymentMethod PaymentMethod { get; init; } + public required BillingAddress BillingAddress { get; init; } + public short AdditionalStorageGb { get; init; } + public string? Coupon { get; init; } +} diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs index 12ea3d5a7c73..e43a91fbefd2 100644 --- a/src/Core/Billing/Services/IStripeAdapter.cs +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -18,6 +18,7 @@ Task CreateCustomerBalanceTransactionAsync(string cu CustomerBalanceTransactionCreateOptions options); Task CreateSubscriptionAsync(SubscriptionCreateOptions subscriptionCreateOptions); Task GetSubscriptionAsync(string id, SubscriptionGetOptions options = null); + Task> ListSubscriptionsAsync(SubscriptionListOptions options = null); Task> ListTaxRegistrationsAsync(RegistrationListOptions options = null); Task DeleteCustomerDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null); Task UpdateSubscriptionAsync(string id, SubscriptionUpdateOptions options = null); diff --git a/src/Core/Billing/Services/ISubscriptionDiscountService.cs b/src/Core/Billing/Services/ISubscriptionDiscountService.cs new file mode 100644 index 000000000000..94ccd964a0d4 --- /dev/null +++ b/src/Core/Billing/Services/ISubscriptionDiscountService.cs @@ -0,0 +1,28 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; + +namespace Bit.Core.Billing.Services; + +public interface ISubscriptionDiscountService +{ + /// + /// Validates whether a user is eligible for a specific discount coupon with a specific audience type during subscription creation. + /// + /// The user attempting to use the discount. + /// The Stripe coupon ID to validate. + /// The expected audience type the discount must target. + /// + /// if the discount exists, is currently active, matches the expected audience type, and the user meets eligibility criteria; + /// otherwise, . + /// + /// + /// This method performs server-side validation to ensure: + /// + /// The discount exists in the database + /// The discount is within its valid date range + /// The discount's audience type matches the expected audience type + /// The user meets the audience targeting criteria for the discount + /// + /// + Task ValidateDiscountForUserAsync(User user, string stripeCouponId, DiscountAudienceType expectedAudienceType); +} diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs index 5b905000216b..405d45aba77d 100644 --- a/src/Core/Billing/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -84,6 +84,9 @@ public Task CreateSubscriptionAsync(SubscriptionCreateOptions opti public Task GetSubscriptionAsync(string id, SubscriptionGetOptions options = null) => _subscriptionService.GetAsync(id, options); + public Task> ListSubscriptionsAsync(SubscriptionListOptions options = null) => + _subscriptionService.ListAsync(options); + public Task UpdateSubscriptionAsync(string id, SubscriptionUpdateOptions options = null) => _subscriptionService.UpdateAsync(id, options); diff --git a/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs b/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs new file mode 100644 index 000000000000..8bfa908389c1 --- /dev/null +++ b/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs @@ -0,0 +1,79 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Subscriptions.Repositories; +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Billing.Services.Implementations; + +public class SubscriptionDiscountService( + ISubscriptionDiscountRepository subscriptionDiscountRepository, + IStripeAdapter stripeAdapter) : ISubscriptionDiscountService +{ + public async Task ValidateDiscountForUserAsync(User user, string stripeCouponId, DiscountAudienceType expectedAudienceType) + { + var discount = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(stripeCouponId); + + if (discount == null) + { + return false; + } + + var now = DateTime.UtcNow; + if (now < discount.StartDate || now > discount.EndDate) + { + return false; + } + + if (discount.AudienceType != expectedAudienceType) + { + return false; + } + + return discount.AudienceType switch + { + DiscountAudienceType.UserHasNoPreviousSubscriptions => + await UserHasNoPreviousSubscriptionsAsync(user), + _ => false + }; + } + + private async Task UserHasNoPreviousSubscriptionsAsync(User user) + { + // Check current premium status + if (user.Premium || !string.IsNullOrEmpty(user.GatewaySubscriptionId)) + { + return false; + } + + // If user has no Stripe customer, they can't have had past subscriptions + if (string.IsNullOrEmpty(user.GatewayCustomerId)) + { + return true; + } + + // Check for any past premium subscriptions in Stripe + var subscriptions = await stripeAdapter.ListSubscriptionsAsync(new SubscriptionListOptions + { + Customer = user.GatewayCustomerId, + Expand = ["data.items.data.price"] + }); + + // Check if any subscription contains premium price IDs + foreach (var subscription in subscriptions.Data) + { + if (HasPremiumPrice(subscription)) + { + return false; + } + } + + return true; + } + + private static bool HasPremiumPrice(Subscription subscription) + { + return subscription.Items?.Data?.Any(item => + item.Price?.Id != null && + (item.Price.Id.StartsWith("premium-annually") || item.Price.Id.StartsWith("premium-monthly"))) ?? false; + } +} diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index 2f278dcd2070..1d123555226e 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -370,6 +370,633 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.00m, tax); + Assert.Equal(44.00m, total); + + // Verify the coupon is correctly applied to Stripe API call + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "TEST_COUPON_20")); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_WithCouponAndStorage_AppliesCouponCorrectly() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = 5, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = 2, + Standalone = false + }, + Coupon = "ENTERPRISE_DISCOUNT_15" + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }], + Total = 16500 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify coupon is applied alongside all subscription items + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 2) && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "ENTERPRISE_DISCOUNT_15")); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SponsoredPlanWithCoupon_IgnoresCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = true + }, + Coupon = "TEST_COUPON_IGNORED" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 500 }], + Total = 5500 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify coupon is ignored for sponsored plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManagerWithCoupon_UsesSystemCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 3, + AdditionalServiceAccounts = 0, + Standalone = true + }, + Coupon = "USER_COUPON_IGNORED" + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 750 }], + Total = 8250 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(7.50m, tax); + Assert.Equal(82.50m, total); + + // Verify user coupon is ignored and system coupon (SecretsManagerStandalone) is used instead + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == CouponIDs.SecretsManagerStandalone)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_EmptyStringCoupon_TreatedAsNull() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = "" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify empty string coupon is treated same as null (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_NullCoupon_NoDiscountApplied() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify null coupon results in no discounts applied + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_WhitespaceOnlyCoupon_TreatedAsNull() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = " " + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + // Whitespace-only strings are now trimmed and treated as null/empty, so no discount is applied + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify whitespace-only coupon is treated as null (no discount applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_CouponWithLeadingTrailingWhitespace_TrimmedBeforeApplying() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = " TEST_COUPON_20 " + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 400 }], + Total = 4400 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + // Coupon with leading and trailing whitespace should be trimmed before applying + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.00m, tax); + Assert.Equal(44.00m, total); + + // Verify the coupon is trimmed before being sent to Stripe + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "TEST_COUPON_20")); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_VeryLongCouponString_PassedThroughToStripe() + { + // Very long coupon string (200 characters) + var longCoupon = new string('A', 200); + + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = longCoupon + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify very long coupon string is passed through to Stripe (Stripe will handle validation) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == longCoupon)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_CouponWithSpecialCharacters_PassedThroughToStripe() + { + // Coupon with special characters (hyphens, underscores, numbers are common in Stripe coupon IDs) + var specialCoupon = "TEST-COUPON_2024-50%OFF"; + + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = specialCoupon + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon with special characters is passed through to Stripe (Stripe will handle validation) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == specialCoupon)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_CouponWithUnicodeCharacters_PassedThroughToStripe() + { + // Coupon with unicode characters (though unlikely for real Stripe coupons, tests edge case) + var unicodeCoupon = "TEST-COUPON-2024"; + + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = unicodeCoupon + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon with unicode characters is passed through to Stripe (Stripe will handle validation) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == unicodeCoupon)); + } + #endregion #region Subscription Plan Change diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 0e38850111bc..7431016e3d21 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,11 +1,13 @@ using Bit.Core.Billing; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; @@ -40,6 +42,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For(); private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand = Substitute.For(); + private readonly ISubscriptionDiscountService _subscriptionDiscountService = Substitute.For(); private readonly CreatePremiumCloudHostedSubscriptionCommand _command; public CreatePremiumCloudHostedSubscriptionCommandTests() @@ -71,20 +74,66 @@ public CreatePremiumCloudHostedSubscriptionCommandTests() Substitute.For>(), _pricingClient, _hasPaymentMethodQuery, - _updatePaymentMethodCommand); + _updatePaymentMethodCommand, + _subscriptionDiscountService); } + #region Helper Methods + + private static PremiumSubscriptionPurchase CreateSubscriptionPurchase( + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb = 0, + string? coupon = null) + { + return new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = additionalStorageGb, + Coupon = coupon + }; + } + + private static StripeCustomer CreateMockCustomer(string customerId = "cust_123", string country = "US", string postalCode = "12345") + { + var mockCustomer = Substitute.For(); + mockCustomer.Id = customerId; + mockCustomer.Address = new Address { Country = country, PostalCode = postalCode }; + mockCustomer.Metadata = new Dictionary(); + return mockCustomer; + } + + private static StripeSubscription CreateMockActiveSubscription(string subscriptionId = "sub_123") + { + var mockSubscription = Substitute.For(); + mockSubscription.Id = subscriptionId; + mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + return mockSubscription; + } + + #endregion + [Theory, BitAutoData] public async Task Run_UserAlreadyPremium_ReturnsBadRequest( User user, - TokenizedPaymentMethod paymentMethod, - BillingAddress billingAddress) + PremiumSubscriptionPurchase subscriptionPurchase) { // Arrange user.Premium = true; // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT1); @@ -95,14 +144,14 @@ public async Task Run_UserAlreadyPremium_ReturnsBadRequest( [Theory, BitAutoData] public async Task Run_NegativeStorageAmount_ReturnsBadRequest( User user, - TokenizedPaymentMethod paymentMethod, - BillingAddress billingAddress) + PremiumSubscriptionPurchase subscriptionPurchase) { // Arrange user.Premium = false; + subscriptionPurchase = subscriptionPurchase with { AdditionalStorageGb = -1 }; // Act - var result = await _command.Run(user, paymentMethod, billingAddress, -1); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT1); @@ -125,6 +174,14 @@ public async Task Run_ValidPaymentMethodTypes_BankAccount_Success( billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -157,7 +214,7 @@ public async Task Run_ValidPaymentMethodTypes_BankAccount_Success( _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -182,6 +239,14 @@ public async Task Run_ValidPaymentMethodTypes_Card_Success( billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -210,7 +275,7 @@ public async Task Run_ValidPaymentMethodTypes_Card_Success( _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -235,6 +300,14 @@ public async Task Run_ValidPaymentMethodTypes_PayPal_Success( billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -258,7 +331,7 @@ public async Task Run_ValidPaymentMethodTypes_PayPal_Success( _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -291,6 +364,14 @@ public async Task Run_ValidRequestWithAdditionalStorage_Success( billingAddress.PostalCode = "12345"; const short additionalStorage = 2; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = additionalStorage, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -319,7 +400,7 @@ public async Task Run_ValidRequestWithAdditionalStorage_Success( _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -345,6 +426,14 @@ public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExist billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "existing_customer_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -373,7 +462,7 @@ public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExist _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -396,6 +485,14 @@ public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesP billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "existing_customer_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -432,7 +529,7 @@ public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesP _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -497,8 +594,16 @@ public async Task Run_PayPalWithIncompleteSubscription_SetsPremiumTrue( _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -554,8 +659,16 @@ public async Task Run_NonPayPalWithActiveSubscription_SetsPremiumTrue( _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -610,8 +723,16 @@ public async Task Run_SubscriptionStatusDoesNotMatchPatterns_DoesNotSetPremium( _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -670,8 +791,16 @@ public async Task Run_BankAccountWithNoSetupIntentFound_ReturnsUnhandled( _stripeAdapter.ListSetupIntentsAsync(Arg.Any()) .Returns(Task.FromResult(new List())); // Empty list - no setup intent found + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT3); @@ -717,8 +846,16 @@ public async Task Run_AccountCredit_WithExistingCustomer_Success( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -742,8 +879,16 @@ public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingEx billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); //Assert Assert.True(result.IsT3); // Assuming T3 is the Unhandled result @@ -800,11 +945,19 @@ public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb( ] }; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = additionalStorage, + Coupon = null + }; + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -856,7 +1009,13 @@ public async Task Run_UserWithCanceledSubscription_AllowsResubscribe( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); // Should succeed, not return "Already a premium user" @@ -910,7 +1069,13 @@ public async Task Run_UserWithIncompleteExpiredSubscription_AllowsResubscribe( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); // Should succeed, not return "Already a premium user" @@ -938,7 +1103,13 @@ public async Task Run_UserWithActiveSubscription_PremiumTrue_ReturnsBadRequest( _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingActiveSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT1); @@ -991,7 +1162,13 @@ public async Task Run_SubscriptionFetchThrows_ProceedsWithCreation( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert - Should proceed successfully despite the exception Assert.True(result.IsT0); @@ -1053,7 +1230,13 @@ public async Task Run_ResubscribeWithTerminalSubscription_UpdatesPaymentMethod( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -1063,4 +1246,133 @@ public async Task Run_ResubscribeWithTerminalSubscription_UpdatesPaymentMethod( await _userService.Received(1).SaveUserAsync(user); } + [Theory, BitAutoData] + public async Task Run_ValidCoupon_AppliesCouponSuccessfully( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "VALID_COUPON"); + var mockCustomer = CreateMockCustomer(); + var mockSubscription = CreateMockActiveSubscription(); + + _subscriptionDiscountService.ValidateDiscountForUserAsync(user, "VALID_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions) + .Returns(true); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + await _subscriptionDiscountService.Received(1).ValidateDiscountForUserAsync(user, "VALID_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && + opts.Discounts.Count == 1 && + opts.Discounts[0].Coupon == "VALID_COUPON")); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_InvalidCoupon_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "INVALID_COUPON"); + + _subscriptionDiscountService.ValidateDiscountForUserAsync(user, "INVALID_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions) + .Returns(false); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("The coupon code is invalid or you are not eligible for this discount.", badRequest.Response); + await _subscriptionDiscountService.Received(1).ValidateDiscountForUserAsync(user, "INVALID_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_UserNotEligibleForCoupon_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + user.GatewaySubscriptionId = null; + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "NEW_USER_ONLY_COUPON"); + + // User has previous subscriptions, so they're not eligible + _subscriptionDiscountService.ValidateDiscountForUserAsync(user, "NEW_USER_ONLY_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions) + .Returns(false); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("The coupon code is invalid or you are not eligible for this discount.", badRequest.Response); + await _subscriptionDiscountService.Received(1).ValidateDiscountForUserAsync(user, "NEW_USER_ONLY_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_CouponWithWhitespace_TrimsCouponCode( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: " WHITESPACE_COUPON "); + var mockCustomer = CreateMockCustomer(); + var mockSubscription = CreateMockActiveSubscription(); + + _subscriptionDiscountService.ValidateDiscountForUserAsync(user, "WHITESPACE_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions) + .Returns(true); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + // Verify the coupon was trimmed before validation + await _subscriptionDiscountService.Received(1).ValidateDiscountForUserAsync(user, "WHITESPACE_COUPON", DiscountAudienceType.UserHasNoPreviousSubscriptions); + // Verify the coupon was trimmed before passing to Stripe + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && + opts.Discounts.Count == 1 && + opts.Discounts[0].Coupon == "WHITESPACE_COUPON")); + } + } diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index b5afaf65cd12..89be828c07b1 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Microsoft.Extensions.Logging; @@ -35,6 +36,28 @@ public PreviewPremiumTaxCommandTests() _command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter); } + #region Helper Methods + + private static PremiumPurchasePreview CreatePreview(short additionalStorageGb = 0, string? coupon = null) + { + return new PremiumPurchasePreview + { + AdditionalStorageGb = additionalStorageGb, + Coupon = coupon + }; + } + + private static BillingAddress CreateBillingAddress(string country = "US", string postalCode = "12345") + { + return new BillingAddress + { + Country = country, + PostalCode = postalCode + }; + } + + #endregion + [Fact] public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() { @@ -52,7 +75,13 @@ public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -86,7 +115,13 @@ public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(5, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 5, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -122,7 +157,13 @@ public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -156,7 +197,13 @@ public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(20, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 20, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -192,7 +239,13 @@ public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(10, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 10, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -228,7 +281,13 @@ public async Task Run_PremiumNoTax_ReturnsZeroTax() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -262,7 +321,13 @@ public async Task Run_NegativeStorage_TreatedAsZero() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(-5, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = -5, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -297,11 +362,203 @@ public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; Assert.Equal(1.23m, tax); Assert.Equal(31.23m, total); } + + [Fact] + public async Task Run_WithValidCoupon_IncludesCouponInInvoicePreview() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupon: "VALID_COUPON_CODE"); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "VALID_COUPON_CODE" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithCouponAndStorage_IncludesBothInInvoicePreview() + { + var billingAddress = CreateBillingAddress(country: "CA", postalCode: "K1A 0A6"); + var preview = CreatePreview(additionalStorageGb: 5, coupon: "STORAGE_DISCOUNT"); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 450 }], + Total = 4950 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.50m, tax); + Assert.Equal(49.50m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "STORAGE_DISCOUNT" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 5))); + } + + [Fact] + public async Task Run_WithCouponWhitespace_TrimsCouponCode() + { + var billingAddress = CreateBillingAddress(country: "GB", postalCode: "SW1A 1AA"); + var preview = CreatePreview(coupon: " WHITESPACE_COUPON "); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 250 }], + Total = 2750 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(2.50m, tax); + Assert.Equal(27.50m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "WHITESPACE_COUPON" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithNullCoupon_ExcludesCouponFromInvoicePreview() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.Discounts == null && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithEmptyCoupon_ExcludesCouponFromInvoicePreview() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = "" + }; + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.Discounts == null && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } } diff --git a/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs b/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs new file mode 100644 index 000000000000..cd30e8a28c40 --- /dev/null +++ b/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs @@ -0,0 +1,351 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Implementations; +using Bit.Core.Billing.Subscriptions.Entities; +using Bit.Core.Billing.Subscriptions.Repositories; +using Bit.Core.Entities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Services; + +[SutProviderCustomize] +public class SubscriptionDiscountServiceTests +{ + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_DiscountNotFound_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .ReturnsNull(); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_DiscountNotYetActive_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_DiscountExpired_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-30), + EndDate = DateTime.UtcNow.AddDays(-1) + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_UserHasCurrentPremiumSubscription_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_UserHasPastPremiumSubscription_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = false; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = "cus_123"; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + var canceledPremiumSubscription = new Subscription + { + Id = "sub_old", + Status = "canceled", + Items = new StripeList + { + Data = new List + { + new() + { + Price = new Price { Id = "premium-annually" } + } + } + } + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + sutProvider.GetDependency() + .ListSubscriptionsAsync(Arg.Is(opts => + opts.Customer == user.GatewayCustomerId)) + .Returns(new StripeList + { + Data = new List { canceledPremiumSubscription } + }); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_UserHasNoPremiumSubscriptions_ReturnsTrue( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = false; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = "cus_123"; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + var nonPremiumSubscription = new Subscription + { + Id = "sub_org", + Status = "active", + Items = new StripeList + { + Data = new List + { + new() + { + Price = new Price { Id = "teams-org-monthly" } + } + } + } + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + sutProvider.GetDependency() + .ListSubscriptionsAsync(Arg.Is(opts => + opts.Customer == user.GatewayCustomerId)) + .Returns(new StripeList + { + Data = new List { nonPremiumSubscription } + }); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_UserHasNoStripeCustomer_ReturnsTrue( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = false; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = null; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_UserHasNoSubscriptions_ReturnsTrue( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = false; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = "cus_123"; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + sutProvider.GetDependency() + .ListSubscriptionsAsync(Arg.Is(opts => + opts.Customer == user.GatewayCustomerId)) + .Returns(new StripeList + { + Data = new List() + }); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_UserHasPremiumMonthlySubscription_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = false; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = "cus_123"; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + var premiumMonthlySubscription = new Subscription + { + Id = "sub_old", + Status = "canceled", + Items = new StripeList + { + Data = new List + { + new() + { + Price = new Price { Id = "premium-monthly" } + } + } + } + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + sutProvider.GetDependency() + .ListSubscriptionsAsync(Arg.Is(opts => + opts.Customer == user.GatewayCustomerId)) + .Returns(new StripeList + { + Data = new List { premiumMonthlySubscription } + }); + + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, DiscountAudienceType.UserHasNoPreviousSubscriptions); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountForUserAsync_AudienceTypeMismatch_ReturnsFalse( + User user, + string stripeCouponId, + SutProvider sutProvider) + { + user.Premium = false; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = null; + + var discount = new SubscriptionDiscount + { + StripeCouponId = stripeCouponId, + AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions, + StartDate = DateTime.UtcNow.AddDays(-1), + EndDate = DateTime.UtcNow.AddDays(30) + }; + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(stripeCouponId) + .Returns(discount); + + // Pass a different audience type than what the discount has + // Note: This currently will fail because there's only one enum value + // This test is future-proof for when more audience types are added + const DiscountAudienceType differentAudienceType = (DiscountAudienceType)999; + var result = await sutProvider.Sut.ValidateDiscountForUserAsync(user, stripeCouponId, differentAudienceType); + + Assert.False(result); + } +}