diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 5a48aa2fe2f0..6373da9902cf 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -81,6 +81,8 @@ public class OrganizationCreateRequestModel : IValidatableObject public bool SkipTrial { get; set; } + public string[] Coupons { get; set; } + public virtual OrganizationSignup ToOrganizationSignup(User user) { var orgSignup = new OrganizationSignup @@ -114,6 +116,7 @@ public virtual OrganizationSignup ToOrganizationSignup(User user) }, InitiationPath = InitiationPath, SkipTrial = SkipTrial, + Coupons = Coupons, Keys = Keys?.ToPublicKeyEncryptionKeyPairData() }; diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs index 71bc7cc860d3..fea3496f0086 100644 --- a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -20,8 +20,7 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject public SecretsManagerPurchaseSelections? SecretsManager { get; set; } - [MaxLength(50)] - public string? Coupon { get; set; } + public string[]? Coupons { get; set; } public OrganizationSubscriptionPurchase ToDomain() => new() { @@ -39,7 +38,7 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, Standalone = SecretsManager.Standalone } : null, - Coupon = Coupon + Coupons = Coupons }; 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 8978b06242c6..162e29ab033b 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -16,8 +16,7 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject [Range(0, 99)] public short AdditionalStorageGb { get; set; } = 0; - [MaxLength(50)] - public string? Coupon { get; set; } + public string[]? Coupons { get; set; } public PremiumSubscriptionPurchase ToDomain() { @@ -36,7 +35,7 @@ public PremiumSubscriptionPurchase ToDomain() PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = AdditionalStorageGb, - Coupon = Coupon + Coupons = Coupons }; } diff --git a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs index a5fdaea64de0..542c60be9dac 100644 --- a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -14,14 +14,13 @@ public record PreviewPremiumSubscriptionPurchaseTaxRequest [Required] public required MinimalBillingAddressRequest BillingAddress { get; set; } - [MaxLength(50)] - public string? Coupon { get; set; } + public string[]? Coupons { get; set; } public (PremiumPurchasePreview, BillingAddress) ToDomain() => ( new PremiumPurchasePreview { AdditionalStorageGb = AdditionalStorage, - Coupon = Coupon + Coupons = Coupons }, BillingAddress.ToDomain()); } diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs index aa67c712b512..e20150524a28 100644 --- a/src/Core/Billing/Models/Sales/CustomerSetup.cs +++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs @@ -8,7 +8,7 @@ public class CustomerSetup { public TokenizedPaymentSource? TokenizedPaymentSource { get; set; } public TaxInformation? TaxInformation { get; set; } - public string? Coupon { get; set; } + public string[]? Coupons { get; set; } public bool IsBillable => TokenizedPaymentSource != null && TaxInformation != null; } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 2f7149c93974..c1fdbf553556 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -126,18 +126,26 @@ public class PreviewOrganizationTaxCommand( } } - // Validate coupon and only apply if valid. If invalid, proceed without the discount. - // Only Families plans support user-provided coupons - if (!string.IsNullOrWhiteSpace(purchase.Coupon) && purchase.Tier == ProductTierType.Families) + // Validate all coupons at once. If all are eligible, apply them; otherwise skip gracefully. + // Only Families plans support user-provided coupons. + if (purchase is { Coupons.Length: > 0, Tier: ProductTierType.Families }) { - var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( - user, - purchase.Coupon.Trim(), - DiscountTierType.Families); + var trimmedCoupons = purchase.Coupons + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()) + .ToArray(); - if (isValid) + if (trimmedCoupons.Length > 0) { - options.Discounts = [new InvoiceDiscountOptions { Coupon = purchase.Coupon.Trim() }]; + var allValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, trimmedCoupons, DiscountTierType.Families); + + if (allValid) + { + options.Discounts = trimmedCoupons + .Select(c => new InvoiceDiscountOptions { Coupon = c }) + .ToList(); + } } } diff --git a/src/Core/Billing/Organizations/Models/OrganizationSale.cs b/src/Core/Billing/Organizations/Models/OrganizationSale.cs index 13b89b0d49d1..7d10461e5efd 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSale.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSale.cs @@ -63,12 +63,12 @@ private static CustomerSetup GetCustomerSetup(OrganizationSignup signup) { var customerSetup = new CustomerSetup { - Coupon = signup.IsFromProvider + Coupons = signup.IsFromProvider // TODO: Remove when last of the legacy providers has been migrated. - ? StripeConstants.CouponIDs.LegacyMSPDiscount + ? [StripeConstants.CouponIDs.LegacyMSPDiscount] : signup.IsFromSecretsManagerTrial - ? StripeConstants.CouponIDs.SecretsManagerStandalone - : null + ? [StripeConstants.CouponIDs.SecretsManagerStandalone] + : signup.Coupons }; if (!signup.PaymentMethodType.HasValue) diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs index 09bda1dde45f..1208a17b16a1 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -8,7 +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 string[]? Coupons { 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 1a1aee25b15f..a8f6f9e797f5 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -39,26 +39,31 @@ public async Task Finalize(OrganizationSale sale) { var (organization, customerSetup, subscriptionSetup, owner) = sale; - // Validate coupon and only apply if valid. If invalid, proceed without the discount. + // Validate all provided coupons. Fail fast if any coupon is invalid. // Validation includes user-specific eligibility checks to ensure the owner has never had premium // and that this is for a Families subscription. - // Only validate discount if owner is provided (i.e., the user performing the upgrade is an owner). - string? validatedCoupon = null; - if (!string.IsNullOrWhiteSpace(customerSetup?.Coupon) && owner != null) + // Only validate discounts if owner is provided (i.e., the user performing the upgrade is an owner). + var validatedCoupons = new List(); + if (customerSetup?.Coupons is { Length: > 0 } && owner != null) { // Only Families plans support user-provided coupons if (subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families) { - var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( - owner, - customerSetup.Coupon.Trim(), - DiscountTierType.Families); + validatedCoupons = customerSetup.Coupons + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()) + .ToList(); - if (!isValid) + if (validatedCoupons.Count > 0) { - throw new BadRequestException("Discount expired. Please review your cart total and try again"); + var allValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + owner, validatedCoupons, DiscountTierType.Families); + + if (!allValid) + { + throw new BadRequestException("Discount expired. Please review your cart total and try again"); + } } - validatedCoupon = customerSetup.Coupon.Trim(); } } @@ -66,7 +71,7 @@ public async Task Finalize(OrganizationSale sale) ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); - var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup, validatedCoupon); + var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup, validatedCoupons); if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) { @@ -372,7 +377,7 @@ private async Task CreateSubscriptionAsync( Organization organization, Customer customer, SubscriptionSetup subscriptionSetup, - string? coupon) + IReadOnlyList coupons) { var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType); @@ -435,7 +440,7 @@ private async Task CreateSubscriptionAsync( { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, - Discounts = !string.IsNullOrWhiteSpace(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }] : null, + Discounts = coupons.Count > 0 ? coupons.Select(c => new SubscriptionDiscountOptions { Coupon = c }).ToList() : 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 7cf517d4cc01..429192c29369 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -83,16 +83,21 @@ public Task> Run( return new BadRequest("Additional storage must be greater than 0."); } - // Validate coupon if provided. Return error if invalid to prevent charging more than expected. - string? validatedCoupon = null; - if (!string.IsNullOrWhiteSpace(subscriptionPurchase.Coupon)) + // Validate all provided coupons. Fail fast if any coupon is invalid to prevent charging more than expected. + var validatedCoupons = (subscriptionPurchase.Coupons ?? []) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()) + .ToList(); + + if (validatedCoupons.Count > 0) { - var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, subscriptionPurchase.Coupon.Trim(), DiscountTierType.Premium); - if (!isValid) + var allValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, validatedCoupons, DiscountTierType.Premium); + + if (!allValid) { return new BadRequest("Discount expired. Please review your cart total and try again"); } - validatedCoupon = subscriptionPurchase.Coupon.Trim(); } var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); @@ -127,7 +132,7 @@ public Task> Run( customer = await ReconcileBillingLocationAsync(customer, subscriptionPurchase.BillingAddress); - var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupon); + var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupons); subscriptionPurchase.PaymentMethod.Switch( tokenized => @@ -307,7 +312,7 @@ private async Task CreateSubscriptionAsync( Customer customer, Pricing.Premium.Plan premiumPlan, int? storage, - string? validatedCoupon) + IReadOnlyList validatedCoupons) { var subscriptionItemOptionsList = new List @@ -349,9 +354,11 @@ private async Task CreateSubscriptionAsync( OffSession = true }; - if (!string.IsNullOrWhiteSpace(validatedCoupon)) + if (validatedCoupons.Count > 0) { - subscriptionCreateOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = validatedCoupon }]; + subscriptionCreateOptions.Discounts = validatedCoupons + .Select(c => new SubscriptionDiscountOptions { Coupon = c }) + .ToList(); } var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 402b77fdb37d..8fc27f539c62 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -62,17 +62,25 @@ public class PreviewPremiumTaxCommand( }); } - // Validate coupon and only apply if valid. If invalid, proceed without the discount. - if (!string.IsNullOrWhiteSpace(preview.Coupon)) + // Validate all coupons at once. If all are eligible, apply them; otherwise skip gracefully. + if (preview.Coupons is { Length: > 0 }) { - var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( - user, - preview.Coupon.Trim(), - DiscountTierType.Premium); + var trimmedCoupons = preview.Coupons + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()) + .ToArray(); - if (isValid) + if (trimmedCoupons.Length > 0) { - options.Discounts = [new InvoiceDiscountOptions { Coupon = preview.Coupon.Trim() }]; + var allValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, trimmedCoupons, DiscountTierType.Premium); + + if (allValid) + { + options.Discounts = trimmedCoupons + .Select(c => new InvoiceDiscountOptions { Coupon = c }) + .ToList(); + } } } diff --git a/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs b/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs index 6a4716b10bb0..88a5ffb17cda 100644 --- a/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs +++ b/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs @@ -3,5 +3,5 @@ public record PremiumPurchasePreview { public short? AdditionalStorageGb { get; init; } - public string? Coupon { get; init; } + public string[]? Coupons { get; init; } } diff --git a/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs b/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs index dcc712c327fd..b61a50df2f4c 100644 --- a/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs +++ b/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs @@ -7,5 +7,5 @@ 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; } + public string[]? Coupons { get; init; } } diff --git a/src/Core/Billing/Services/ISubscriptionDiscountService.cs b/src/Core/Billing/Services/ISubscriptionDiscountService.cs index f2f5bb7b7b57..63e774483485 100644 --- a/src/Core/Billing/Services/ISubscriptionDiscountService.cs +++ b/src/Core/Billing/Services/ISubscriptionDiscountService.cs @@ -17,12 +17,12 @@ public interface ISubscriptionDiscountService Task> GetEligibleDiscountsAsync(User user); /// - /// Performs a server-side eligibility recheck for a specific coupon before subscription creation, - /// confirming the coupon exists, is active, and the user still qualifies for it on the specified tier. + /// Performs a server-side eligibility recheck for the provided coupon IDs before subscription creation, + /// confirming every coupon exists, is active, and the user qualifies for each on the specified tier. /// /// The user to validate eligibility for. - /// The Stripe coupon ID to validate. + /// The Stripe coupon IDs to validate. /// The product tier the user intends to subscribe to. - /// if the discount exists and the user is eligible for the given tier; otherwise . - Task ValidateDiscountEligibilityForUserAsync(User user, string coupon, DiscountTierType tierType); + /// if all coupons are found in the user's eligible discounts and tier eligibility is for ; otherwise . + Task ValidateDiscountEligibilityForUserAsync(User user, IReadOnlyList couponIds, DiscountTierType tierType); } diff --git a/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs b/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs index 81468e3f690a..ba4e1fe070cb 100644 --- a/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs @@ -34,16 +34,13 @@ public async Task> GetEligibleDiscountsAsync(Us } /// - public async Task ValidateDiscountEligibilityForUserAsync(User user, string coupon, DiscountTierType tierType) + public async Task ValidateDiscountEligibilityForUserAsync(User user, IReadOnlyList couponIds, DiscountTierType tierType) { - var discount = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(coupon); - if (discount == null || !IsDiscountActive(discount)) - { - return false; - } - - var tierEligibility = await GetTierEligibilityAsync(user, discount); - return tierEligibility is not null && tierEligibility[tierType]; + var eligibleDiscounts = await GetEligibleDiscountsAsync(user); + var eligibilityByStripeCouponId = eligibleDiscounts.ToDictionary(d => d.Discount.StripeCouponId); + return couponIds.All(id => + eligibilityByStripeCouponId.TryGetValue(id, out var eligibility) && + eligibility.TierEligibility[tierType]); } /// @@ -56,15 +53,4 @@ public async Task ValidateDiscountEligibilityForUserAsync(User user, strin var filter = discountAudienceFilterFactory.GetFilter(discount.AudienceType); return filter is not null ? await filter.IsUserEligible(user, discount) : null; } - - /// - /// Checks if a discount is currently active based on its start and end dates. - /// - /// The discount to check. - /// if the current time is within the discount's valid date range; otherwise, . - private static bool IsDiscountActive(SubscriptionDiscount discount) - { - var now = DateTime.UtcNow; - return now >= discount.StartDate && now <= discount.EndDate; - } } diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index be79e7180779..ec0ddf63f233 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -20,4 +20,5 @@ public class OrganizationSignup : OrganizationUpgrade public bool IsFromSecretsManagerTrial { get; set; } public bool IsFromProvider { get; set; } public bool SkipTrial { get; set; } + public string[] Coupons { get; set; } } diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index f30e6fb1d972..cd0cde2c6009 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -438,7 +438,7 @@ public async Task Run_OrganizationSubscriptionPurchase_TeamsWithCoupon_IgnoresCo AdditionalStorage = 0, Sponsored = false }, - Coupon = "TEST_COUPON_20" + Coupons = ["TEST_COUPON_20"] }; var billingAddress = new BillingAddress @@ -497,7 +497,7 @@ public async Task Run_OrganizationSubscriptionPurchase_EnterpriseWithCoupon_Igno AdditionalServiceAccounts = 2, Standalone = false }, - Coupon = "ENTERPRISE_DISCOUNT_15" + Coupons = ["ENTERPRISE_DISCOUNT_15"] }; var billingAddress = new BillingAddress @@ -556,7 +556,7 @@ public async Task Run_OrganizationSubscriptionPurchase_SponsoredPlanWithCoupon_I AdditionalStorage = 0, Sponsored = true }, - Coupon = "TEST_COUPON_IGNORED" + Coupons = ["TEST_COUPON_IGNORED"] }; var billingAddress = new BillingAddress @@ -615,7 +615,7 @@ public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManagerW AdditionalServiceAccounts = 0, Standalone = true }, - Coupon = "USER_COUPON_IGNORED" + Coupons = ["USER_COUPON_IGNORED"] }; var billingAddress = new BillingAddress @@ -672,7 +672,7 @@ public async Task Run_OrganizationSubscriptionPurchase_EmptyStringCoupon_Treated AdditionalStorage = 0, Sponsored = false }, - Coupon = "" + Coupons = null }; var billingAddress = new BillingAddress @@ -777,7 +777,7 @@ public async Task Run_OrganizationSubscriptionPurchase_WhitespaceOnlyCoupon_Trea AdditionalStorage = 0, Sponsored = false }, - Coupon = " " + Coupons = [" "] }; var billingAddress = new BillingAddress @@ -831,7 +831,7 @@ public async Task Run_OrganizationSubscriptionPurchase_TeamsWithCouponWithWhites AdditionalStorage = 0, Sponsored = false }, - Coupon = " TEST_COUPON_20 " + Coupons = [" TEST_COUPON_20 "] }; var billingAddress = new BillingAddress @@ -887,7 +887,7 @@ public async Task Run_OrganizationSubscriptionPurchase_TeamsWithLongCoupon_Ignor AdditionalStorage = 0, Sponsored = false }, - Coupon = longCoupon + Coupons = [longCoupon] }; var billingAddress = new BillingAddress @@ -943,7 +943,7 @@ public async Task Run_OrganizationSubscriptionPurchase_TeamsWithSpecialCharacter AdditionalStorage = 0, Sponsored = false }, - Coupon = specialCoupon + Coupons = [specialCoupon] }; var billingAddress = new BillingAddress @@ -999,7 +999,7 @@ public async Task Run_OrganizationSubscriptionPurchase_TeamsWithUnicodeCoupon_Ig AdditionalStorage = 0, Sponsored = false }, - Coupon = unicodeCoupon + Coupons = [unicodeCoupon] }; var billingAddress = new BillingAddress @@ -2094,7 +2094,7 @@ public async Task Run_FamiliesOrganizationWithValidCoupon_ValidatesCouponAndAppl AdditionalStorage = 0, Sponsored = false }, - Coupon = "VALID_FAMILIES_DISCOUNT" + Coupons = ["VALID_FAMILIES_DISCOUNT"] }; var billingAddress = new BillingAddress @@ -2108,7 +2108,7 @@ public async Task Run_FamiliesOrganizationWithValidCoupon_ValidatesCouponAndAppl _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( _user, - "VALID_FAMILIES_DISCOUNT", + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_FAMILIES_DISCOUNT" })), DiscountTierType.Families).Returns(true); var invoice = new Invoice @@ -2128,7 +2128,7 @@ public async Task Run_FamiliesOrganizationWithValidCoupon_ValidatesCouponAndAppl await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( _user, - "VALID_FAMILIES_DISCOUNT", + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_FAMILIES_DISCOUNT" })), DiscountTierType.Families); await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => @@ -2150,7 +2150,7 @@ public async Task Run_FamiliesOrganizationWithInvalidCoupon_ProceedsWithoutDisco AdditionalStorage = 0, Sponsored = false }, - Coupon = "INVALID_COUPON" + Coupons = ["INVALID_COUPON"] }; var billingAddress = new BillingAddress @@ -2164,7 +2164,7 @@ public async Task Run_FamiliesOrganizationWithInvalidCoupon_ProceedsWithoutDisco _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( _user, - "INVALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Families).Returns(false); var invoice = new Invoice @@ -2184,7 +2184,7 @@ public async Task Run_FamiliesOrganizationWithInvalidCoupon_ProceedsWithoutDisco await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( _user, - "INVALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Families); // Verify invalid coupon is silently ignored (no discount applied) @@ -2213,7 +2213,7 @@ public async Task Run_TeamsOrganizationWithCoupon_IgnoresCoupon() AdditionalStorage = 0, Sponsored = false }, - Coupon = "TEAMS_COUPON" + Coupons = ["TEAMS_COUPON"] }; var billingAddress = new BillingAddress @@ -2243,7 +2243,7 @@ public async Task Run_TeamsOrganizationWithCoupon_IgnoresCoupon() // Verify coupon validation was NOT called for Teams (only Families plans use coupons) await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( Arg.Any(), - Arg.Any(), + Arg.Any>(), Arg.Any()); // Verify coupon is ignored for Teams plans (no discounts applied) @@ -2272,7 +2272,7 @@ public async Task Run_EnterpriseOrganizationWithCoupon_IgnoresCoupon() AdditionalStorage = 0, Sponsored = false }, - Coupon = "ENTERPRISE_COUPON" + Coupons = ["ENTERPRISE_COUPON"] }; var billingAddress = new BillingAddress @@ -2302,7 +2302,7 @@ public async Task Run_EnterpriseOrganizationWithCoupon_IgnoresCoupon() // Verify coupon validation was NOT called for Enterprise (only Families plans use coupons) await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( Arg.Any(), - Arg.Any(), + Arg.Any>(), Arg.Any()); // Verify coupon is ignored for Enterprise plans (no discounts applied) @@ -2319,4 +2319,141 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })), + DiscountTierType.Families).Returns(true); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 200 }], + Total = 2200 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts != null && + options.Discounts.Count == 2 && + options.Discounts.Any(d => d.Coupon == "COUPON_ONE") && + options.Discounts.Any(d => d.Coupon == "COUPON_TWO"))); + } + + [Fact] + public async Task Run_WithStandaloneSecretsManagerAndCoupons_IgnoresUserCoupons() + { + 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 + }, + Coupons = ["COUPON_ONE", "COUPON_TWO"] + }; + + 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 = 500 }], + Total = 5500 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + + // User coupons ignored; system coupon applied for standalone SM + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == CouponIDs.SecretsManagerStandalone)); + } + + [Fact] + public async Task Run_WithMixedValidAndInvalidCoupons_SkipsAllDiscounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = false + }, + Coupons = ["VALID_COUPON", "INVALID_COUPON"] + }; + + var billingAddress = new BillingAddress { Country = "US", PostalCode = "12345" }; + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON", "INVALID_COUPON" })), + DiscountTierType.Families).Returns(false); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts == null || options.Discounts.Count == 0)); + } + + #endregion } diff --git a/test/Core.Test/Billing/Organizations/Models/OrganizationSaleTests.cs b/test/Core.Test/Billing/Organizations/Models/OrganizationSaleTests.cs new file mode 100644 index 000000000000..a6be748a2c61 --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Models/OrganizationSaleTests.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Models.Business; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.Models; + +public class OrganizationSaleTests +{ + [Fact] + public void From_WithUserCoupons_PopulatesCustomerSetupCoupons() + { + var organization = new Organization(); + var signup = new OrganizationSignup + { + IsFromProvider = false, + IsFromSecretsManagerTrial = false, + Coupons = new[] { "COUPON_ONE", "COUPON_TWO" } + }; + + var sale = OrganizationSale.From(organization, signup); + + Assert.NotNull(sale.CustomerSetup); + Assert.Equal(new[] { "COUPON_ONE", "COUPON_TWO" }, sale.CustomerSetup.Coupons); + } + + [Fact] + public void From_WithNoCoupons_CustomerSetupCouponsIsNull() + { + var organization = new Organization(); + var signup = new OrganizationSignup + { + IsFromProvider = false, + IsFromSecretsManagerTrial = false, + Coupons = null + }; + + var sale = OrganizationSale.From(organization, signup); + + Assert.NotNull(sale.CustomerSetup); + Assert.Null(sale.CustomerSetup.Coupons); + } + + [Fact] + public void From_WithProviderSignup_UsesMSPCouponAndIgnoresUserCoupons() + { + var organization = new Organization(); + var signup = new OrganizationSignup + { + IsFromProvider = true, + Coupons = ["USER_COUPON"] + }; + + var sale = OrganizationSale.From(organization, signup); + + Assert.NotNull(sale.CustomerSetup); + Assert.Equal(new[] { StripeConstants.CouponIDs.LegacyMSPDiscount }, sale.CustomerSetup.Coupons); + } + + [Fact] + public void From_WithSMTrialSignup_UsesSMCouponAndIgnoresUserCoupons() + { + var organization = new Organization(); + var signup = new OrganizationSignup + { + IsFromProvider = false, + IsFromSecretsManagerTrial = true, + Coupons = ["USER_COUPON"] + }; + + var sale = OrganizationSale.From(organization, signup); + + Assert.NotNull(sale.CustomerSetup); + Assert.Equal(new[] { StripeConstants.CouponIDs.SecretsManagerStandalone }, sale.CustomerSetup.Coupons); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 2ef91d54533b..e4eb8f24e93d 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -81,14 +81,14 @@ private static PremiumSubscriptionPurchase CreateSubscriptionPurchase( TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb = 0, - string? coupon = null) + string[]? coupons = null) { return new PremiumSubscriptionPurchase { PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = additionalStorageGb, - Coupon = coupon + Coupons = coupons }; } @@ -176,7 +176,7 @@ public async Task Run_ValidPaymentMethodTypes_Card_Success( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var mockCustomer = Substitute.For(); @@ -237,7 +237,7 @@ public async Task Run_ValidPaymentMethodTypes_PayPal_Success( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var mockCustomer = Substitute.For(); @@ -301,7 +301,7 @@ public async Task Run_ValidRequestWithAdditionalStorage_Success( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = additionalStorage, - Coupon = null + Coupons = null }; var mockCustomer = Substitute.For(); @@ -363,7 +363,7 @@ public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExist PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var mockCustomer = Substitute.For(); @@ -422,7 +422,7 @@ public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesP PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var mockCustomer = Substitute.For(); @@ -531,7 +531,7 @@ public async Task Run_PayPalWithIncompleteSubscription_SetsPremiumTrue( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; // Act @@ -596,7 +596,7 @@ public async Task Run_NonPayPalWithActiveSubscription_SetsPremiumTrue( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; // Act @@ -660,7 +660,7 @@ public async Task Run_SubscriptionStatusDoesNotMatchPatterns_DoesNotSetPremium( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; // Act @@ -721,7 +721,7 @@ public async Task Run_AccountCredit_WithExistingCustomer_Success( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; // Act @@ -754,7 +754,7 @@ public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingEx PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; // Act @@ -820,7 +820,7 @@ public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb( PaymentMethod = paymentMethod, BillingAddress = billingAddress, AdditionalStorageGb = additionalStorage, - Coupon = null + Coupons = null }; _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); @@ -1129,11 +1129,12 @@ public async Task Run_ValidCoupon_AppliesCouponSuccessfully( paymentMethod.Type = TokenizablePaymentMethodType.Card; paymentMethod.Token = "card_token_123"; - var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "VALID_COUPON"); + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupons: ["VALID_COUPON"]); var mockCustomer = CreateMockCustomer(); var mockSubscription = CreateMockActiveSubscription(); - _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "VALID_COUPON", DiscountTierType.Premium) + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON" })), DiscountTierType.Premium) .Returns(true); _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); @@ -1145,7 +1146,8 @@ public async Task Run_ValidCoupon_AppliesCouponSuccessfully( // Assert Assert.True(result.IsT0); - await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "VALID_COUPON", DiscountTierType.Premium); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON" })), DiscountTierType.Premium); await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => opts.Discounts != null && opts.Discounts.Count == 1 && @@ -1167,9 +1169,10 @@ public async Task Run_InvalidCoupon_ReturnsBadRequest( paymentMethod.Type = TokenizablePaymentMethodType.Card; paymentMethod.Token = "card_token_123"; - var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "INVALID_COUPON"); + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupons: ["INVALID_COUPON"]); - _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "INVALID_COUPON", DiscountTierType.Premium) + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Premium) .Returns(false); // Act @@ -1179,7 +1182,8 @@ public async Task Run_InvalidCoupon_ReturnsBadRequest( Assert.True(result.IsT1); var badRequest = result.AsT1; Assert.Equal("Discount expired. Please review your cart total and try again", badRequest.Response); - await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "INVALID_COUPON", DiscountTierType.Premium); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Premium); await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(Arg.Any()); @@ -1199,10 +1203,11 @@ public async Task Run_UserNotEligibleForCoupon_ReturnsBadRequest( paymentMethod.Type = TokenizablePaymentMethodType.Card; paymentMethod.Token = "card_token_123"; - var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "NEW_USER_ONLY_COUPON"); + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupons: ["NEW_USER_ONLY_COUPON"]); // User has previous subscriptions, so they're not eligible - _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "NEW_USER_ONLY_COUPON", DiscountTierType.Premium) + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY_COUPON" })), DiscountTierType.Premium) .Returns(false); // Act @@ -1212,7 +1217,8 @@ public async Task Run_UserNotEligibleForCoupon_ReturnsBadRequest( Assert.True(result.IsT1); var badRequest = result.AsT1; Assert.Equal("Discount expired. Please review your cart total and try again", badRequest.Response); - await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "NEW_USER_ONLY_COUPON", DiscountTierType.Premium); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY_COUPON" })), DiscountTierType.Premium); await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(Arg.Any()); @@ -1231,11 +1237,12 @@ public async Task Run_CouponWithWhitespace_TrimsCouponCode( paymentMethod.Type = TokenizablePaymentMethodType.Card; paymentMethod.Token = "card_token_123"; - var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: " WHITESPACE_COUPON "); + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupons: [" WHITESPACE_COUPON "]); var mockCustomer = CreateMockCustomer(); var mockSubscription = CreateMockActiveSubscription(); - _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "WHITESPACE_COUPON", DiscountTierType.Premium) + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "WHITESPACE_COUPON" })), DiscountTierType.Premium) .Returns(true); _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); @@ -1248,7 +1255,8 @@ public async Task Run_CouponWithWhitespace_TrimsCouponCode( // Assert Assert.True(result.IsT0); // Verify the coupon was trimmed before validation - await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "WHITESPACE_COUPON", DiscountTierType.Premium); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "WHITESPACE_COUPON" })), DiscountTierType.Premium); // Verify the coupon was trimmed before passing to Stripe await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => opts.Discounts != null && @@ -1256,4 +1264,130 @@ await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })), DiscountTierType.Premium).Returns(true); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && + opts.Discounts.Count == 2 && + opts.Discounts.Any(d => d.Coupon == "COUPON_ONE") && + opts.Discounts.Any(d => d.Coupon == "COUPON_TWO"))); + } + + [Theory, BitAutoData] + public async Task Run_WithOneInvalidCoupon_ReturnsBadRequest( + 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, coupons: ["VALID_COUPON", "INVALID_COUPON"]); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON", "INVALID_COUPON" })), DiscountTierType.Premium).Returns(false); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT1); + Assert.Equal("Discount expired. Please review your cart total and try again", result.AsT1.Response); + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_WithNullCoupons_CreatesSubscriptionWithoutDiscount( + 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, coupons: null); + var mockCustomer = CreateMockCustomer(); + var mockSubscription = CreateMockActiveSubscription(); + + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts == null)); + } + + [Theory, BitAutoData] + public async Task Run_WithEmptyCouponsArray_CreatesSubscriptionWithoutDiscount( + 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, coupons: []); + var mockCustomer = CreateMockCustomer(); + var mockSubscription = CreateMockActiveSubscription(); + + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts == null)); + } + } diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index 1a06dc90cbbb..43cc174babfe 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -44,12 +44,12 @@ public PreviewPremiumTaxCommandTests() #region Helper Methods - private static PremiumPurchasePreview CreatePreview(short additionalStorageGb = 0, string? coupon = null) + private static PremiumPurchasePreview CreatePreview(short additionalStorageGb = 0, string[]? coupons = null) { return new PremiumPurchasePreview { AdditionalStorageGb = additionalStorageGb, - Coupon = coupon + Coupons = coupons }; } @@ -84,7 +84,7 @@ public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -124,7 +124,7 @@ public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 5, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -166,7 +166,7 @@ public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -206,7 +206,7 @@ public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 20, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -248,7 +248,7 @@ public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 10, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -290,7 +290,7 @@ public async Task Run_PremiumNoTax_ReturnsZeroTax() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -330,7 +330,7 @@ public async Task Run_NegativeStorage_TreatedAsZero() var preview = new PremiumPurchasePreview { AdditionalStorageGb = -5, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -371,7 +371,7 @@ public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var result = await _command.Run(_user, preview, billingAddress); @@ -386,11 +386,11 @@ public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() public async Task Run_WithValidCoupon_IncludesCouponInInvoicePreview() { var billingAddress = CreateBillingAddress(); - var preview = CreatePreview(coupon: "VALID_COUPON_CODE"); + var preview = CreatePreview(coupons: ["VALID_COUPON_CODE"]); _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( _user, - "VALID_COUPON_CODE", + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON_CODE" })), DiscountTierType.Premium).Returns(true); var invoice = new Invoice @@ -425,11 +425,11 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is>(a => a.SequenceEqual(new[] { "STORAGE_DISCOUNT" })), DiscountTierType.Premium).Returns(true); var invoice = new Invoice @@ -466,11 +466,11 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is>(a => a.SequenceEqual(new[] { "WHITESPACE_COUPON" })), DiscountTierType.Premium).Returns(true); var invoice = new Invoice @@ -513,7 +513,7 @@ public async Task Run_WithNullCoupon_ExcludesCouponFromInvoicePreview() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, - Coupon = null + Coupons = null }; var invoice = new Invoice @@ -554,7 +554,7 @@ public async Task Run_WithEmptyCoupon_ExcludesCouponFromInvoicePreview() var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, - Coupon = "" + Coupons = [""] }; var invoice = new Invoice @@ -587,11 +587,11 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is>(a => a.SequenceEqual(new[] { "VALID_DISCOUNT" })), DiscountTierType.Premium).Returns(true); var invoice = new Invoice @@ -611,7 +611,7 @@ public async Task Run_WithValidCoupon_ValidatesCouponAndAppliesDiscount() await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( _user, - "VALID_DISCOUNT", + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_DISCOUNT" })), DiscountTierType.Premium); await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => @@ -624,11 +624,11 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Premium).Returns(false); var invoice = new Invoice @@ -648,7 +648,7 @@ public async Task Run_WithInvalidCoupon_IgnoresCouponAndProceeds() await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( _user, - "INVALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Premium); await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => @@ -659,12 +659,12 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY" })), DiscountTierType.Premium).Returns(false); var invoice = new Invoice @@ -684,10 +684,117 @@ public async Task Run_WithCouponForUserWithPreviousSubscription_IgnoresCouponAnd await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( _user, - "NEW_USER_ONLY", + Arg.Is>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY" })), DiscountTierType.Premium); await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.Discounts == null || options.Discounts.Count == 0)); } + + [Fact] + public async Task Run_WithMultipleValidCoupons_AppliesBothToInvoicePreview() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupons: ["COUPON_ONE", "COUPON_TWO"]); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + Arg.Is>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })), + DiscountTierType.Premium).Returns(true); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 200 }], + Total = 2200 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts != null && + options.Discounts.Count == 2 && + options.Discounts.Any(d => d.Coupon == "COUPON_ONE") && + options.Discounts.Any(d => d.Coupon == "COUPON_TWO"))); + } + + [Fact] + public async Task Run_WithMixedValidAndInvalidCoupons_SkipsAllDiscounts() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupons: ["VALID_COUPON", "INVALID_COUPON"]); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON", "INVALID_COUPON" })), + DiscountTierType.Premium).Returns(false); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts == null || options.Discounts.Count == 0)); + } + + [Fact] + public async Task Run_WithNullCoupons_DoesNotApplyDiscounts() + { + var billingAddress = CreateBillingAddress(); + var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, Coupons = null }; + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts == null)); + } + + [Fact] + public async Task Run_WithEmptyCouponsArray_DoesNotApplyDiscounts() + { + var billingAddress = CreateBillingAddress(); + var preview = new PremiumPurchasePreview { AdditionalStorageGb = 0, Coupons = [] }; + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts == null)); + } } diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 6b943b746101..35d6a5956343 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -273,7 +273,7 @@ public async Task Finalize_WithValidCoupon_SuccessfullyCreatesSubscription( var customerSetup = new CustomerSetup { - Coupon = "VALID_COUPON" + Coupons = ["VALID_COUPON"] }; var subscriptionSetup = new SubscriptionSetup @@ -304,7 +304,7 @@ public async Task Finalize_WithValidCoupon_SuccessfullyCreatesSubscription( sutProvider.GetDependency() .ValidateDiscountEligibilityForUserAsync( owner, - "VALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON" })), DiscountTierType.Families) .Returns(true); @@ -342,7 +342,7 @@ await sutProvider.GetDependency() .Received(1) .ValidateDiscountEligibilityForUserAsync( owner, - "VALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON" })), DiscountTierType.Families); await sutProvider.GetDependency() @@ -365,7 +365,7 @@ public async Task Finalize_WithInvalidCoupon_ThrowsBadRequestException( var customerSetup = new CustomerSetup { - Coupon = "INVALID_COUPON" + Coupons = ["INVALID_COUPON"] }; var subscriptionSetup = new SubscriptionSetup @@ -397,7 +397,7 @@ public async Task Finalize_WithInvalidCoupon_ThrowsBadRequestException( sutProvider.GetDependency() .ValidateDiscountEligibilityForUserAsync( owner, - "INVALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Families) .Returns(false); @@ -423,7 +423,7 @@ await sutProvider.GetDependency() .Received(1) .ValidateDiscountEligibilityForUserAsync( owner, - "INVALID_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Families); // Verify subscription was NOT created @@ -446,7 +446,7 @@ public async Task Finalize_WithNullCoupon_SkipsValidation( var customerSetup = new CustomerSetup { - Coupon = null + Coupons = null }; var subscriptionSetup = new SubscriptionSetup @@ -506,7 +506,7 @@ public async Task Finalize_WithNullCoupon_SkipsValidation( // Assert - Validation should NOT be called await sutProvider.GetDependency() .DidNotReceive() - .ValidateDiscountEligibilityForUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + .ValidateDiscountEligibilityForUserAsync(Arg.Any(), Arg.Any>(), Arg.Any()); // Subscription should still be created await sutProvider.GetDependency() @@ -528,7 +528,7 @@ public async Task Finalize_WithCouponOutsideDateRange_ThrowsBadRequestException( var customerSetup = new CustomerSetup { - Coupon = "EXPIRED_COUPON" + Coupons = ["EXPIRED_COUPON"] }; var subscriptionSetup = new SubscriptionSetup @@ -560,7 +560,7 @@ public async Task Finalize_WithCouponOutsideDateRange_ThrowsBadRequestException( sutProvider.GetDependency() .ValidateDiscountEligibilityForUserAsync( owner, - "EXPIRED_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "EXPIRED_COUPON" })), DiscountTierType.Families) .Returns(false); @@ -586,7 +586,7 @@ await sutProvider.GetDependency() .Received(1) .ValidateDiscountEligibilityForUserAsync( owner, - "EXPIRED_COUPON", + Arg.Is>(a => a.SequenceEqual(new[] { "EXPIRED_COUPON" })), DiscountTierType.Families); // Verify subscription was NOT created @@ -595,6 +595,174 @@ await sutProvider.GetDependency() .CreateSubscriptionAsync(Arg.Any()); } + [Theory, BitAutoData] + public async Task Finalize_WithMultipleValidCoupons_AppliesAllToSubscription( + Organization organization, + User owner, + SutProvider sutProvider) + { + // Arrange + var plan = MockPlans.Get(PlanType.FamiliesAnnually); + organization.PlanType = PlanType.FamiliesAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var customerSetup = new CustomerSetup + { + Coupons = ["COUPON_ONE", "COUPON_TWO"] + }; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.FamiliesAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup, + Owner = owner + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.FamiliesAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .ValidateDiscountEligibilityForUserAsync( + owner, + Arg.Is>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })), + DiscountTierType.Families) + .Returns(true); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + sutProvider.GetDependency() + .CreateSubscriptionAsync(Arg.Any()) + .Returns(new Subscription + { + Id = "sub_test123", + Status = StripeConstants.SubscriptionStatus.Active + }); + + sutProvider.GetDependency() + .ReplaceAsync(organization) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.Finalize(sale); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ValidateDiscountEligibilityForUserAsync( + owner, + Arg.Is>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })), + DiscountTierType.Families); + + await sutProvider.GetDependency() + .Received(1) + .CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && + opts.Discounts.Count == 2 && + opts.Discounts.Any(d => d.Coupon == "COUPON_ONE") && + opts.Discounts.Any(d => d.Coupon == "COUPON_TWO"))); + } + + [Theory, BitAutoData] + public async Task Finalize_WithOneInvalidCoupon_ThrowsBadRequestException( + Organization organization, + User owner, + SutProvider sutProvider) + { + // Arrange + var plan = MockPlans.Get(PlanType.FamiliesAnnually); + organization.PlanType = PlanType.FamiliesAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var customerSetup = new CustomerSetup + { + Coupons = ["VALID_COUPON", "INVALID_COUPON"] + }; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.FamiliesAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup, + Owner = owner + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.FamiliesAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .ValidateDiscountEligibilityForUserAsync( + owner, + Arg.Is>(a => a.SequenceEqual(new[] { "VALID_COUPON", "INVALID_COUPON" })), + DiscountTierType.Families) + .Returns(false); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Finalize(sale)); + Assert.Equal("Discount expired. Please review your cart total and try again", exception.Message); + + // Verify subscription was NOT created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateSubscriptionAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task Finalize_BusinessWithExemptStatus_DoesNotUpdateTaxExemption( Organization organization, diff --git a/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs b/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs index 1a6f9f687444..caef94752d9a 100644 --- a/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs @@ -177,24 +177,24 @@ public async Task GetEligibleDiscountsAsync_MixedDiscounts_ReturnsOnlyEligible( } [Theory, BitAutoData] - public async Task ValidateDiscountEligibilityForUserAsync_CouponNotFound_ReturnsFalse( + public async Task ValidateDiscountEligibilityForUserAsync_CouponNotInEligibleDiscounts_ReturnsFalse( User user, SutProvider sutProvider) { - // Arrange + // Arrange — no active discounts, so the requested coupon won't be found sutProvider.GetDependency() - .GetByStripeCouponIdAsync("invalid") - .ReturnsNull(); + .GetActiveDiscountsAsync() + .Returns([]); // Act - var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(user, "invalid", DiscountTierType.Premium); + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(user, ["invalid"], DiscountTierType.Premium); // Assert Assert.False(result); } [Theory, BitAutoData] - public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_UserIsEligible_ReturnsTrue( + public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_TierEligible_ReturnsTrue( User user, SubscriptionDiscount discount, SutProvider sutProvider) @@ -205,8 +205,8 @@ public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_UserIsElig discount.EndDate = DateTime.UtcNow.AddDays(30); sutProvider.GetDependency() - .GetByStripeCouponIdAsync(discount.StripeCouponId) - .Returns(discount); + .GetActiveDiscountsAsync() + .Returns([discount]); var filter = Substitute.For(); filter.IsUserEligible(user, discount).Returns(DiscountDictionary(true)); @@ -215,26 +215,27 @@ public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_UserIsElig .Returns(filter); // Act - var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(user, discount.StripeCouponId, DiscountTierType.Premium); + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync( + user, [discount.StripeCouponId], DiscountTierType.Premium); // Assert Assert.True(result); } [Theory, BitAutoData] - public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_UserIsNotEligible_ReturnsFalse( + public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_TierNotEligible_ReturnsFalse( User user, SubscriptionDiscount discount, SutProvider sutProvider) { - // Arrange + // Arrange — discount exists and is active but user is not eligible for this audience type discount.AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions; discount.StartDate = DateTime.UtcNow.AddDays(-1); discount.EndDate = DateTime.UtcNow.AddDays(30); sutProvider.GetDependency() - .GetByStripeCouponIdAsync(discount.StripeCouponId) - .Returns(discount); + .GetActiveDiscountsAsync() + .Returns([discount]); var filter = Substitute.For(); filter.IsUserEligible(user, discount).Returns(DiscountDictionary(false)); @@ -243,7 +244,8 @@ public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_UserIsNotE .Returns(filter); // Act - var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(user, discount.StripeCouponId, DiscountTierType.Families); + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync( + user, [discount.StripeCouponId], DiscountTierType.Families); // Assert Assert.False(result); @@ -255,22 +257,93 @@ public async Task ValidateDiscountEligibilityForUserAsync_InactiveDiscount_Retur SubscriptionDiscount discount, SutProvider sutProvider) { - // Arrange + // Arrange — expired discount is not returned by GetActiveDiscountsAsync, so won't appear in eligible set discount.StartDate = DateTime.UtcNow.AddDays(-30); - discount.EndDate = DateTime.UtcNow.AddDays(-1); // Expired discount + discount.EndDate = DateTime.UtcNow.AddDays(-1); + + sutProvider.GetDependency() + .GetActiveDiscountsAsync() + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync( + user, [discount.StripeCouponId], DiscountTierType.Premium); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountEligibilityForUserAsync_MultipleCoupons_AllEligible_ReturnsTrue( + User user, + SubscriptionDiscount discount1, + SubscriptionDiscount discount2, + SutProvider sutProvider) + { + // Arrange + discount1.AudienceType = DiscountAudienceType.AllUsers; + discount1.StartDate = DateTime.UtcNow.AddDays(-1); + discount1.EndDate = DateTime.UtcNow.AddDays(30); + discount2.AudienceType = DiscountAudienceType.AllUsers; + discount2.StartDate = DateTime.UtcNow.AddDays(-1); + discount2.EndDate = DateTime.UtcNow.AddDays(30); + + sutProvider.GetDependency() + .GetActiveDiscountsAsync() + .Returns([discount1, discount2]); + + var filter = Substitute.For(); + filter.IsUserEligible(user, discount1).Returns(DiscountDictionary(true)); + filter.IsUserEligible(user, discount2).Returns(DiscountDictionary(true)); + sutProvider.GetDependency() + .GetFilter(DiscountAudienceType.AllUsers) + .Returns(filter); + + // Act + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync( + user, [discount1.StripeCouponId, discount2.StripeCouponId], DiscountTierType.Premium); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task ValidateDiscountEligibilityForUserAsync_MultipleCoupons_OneNotEligible_ReturnsFalse( + User user, + SubscriptionDiscount discount1, + SubscriptionDiscount discount2, + SutProvider sutProvider) + { + // Arrange — discount1 is eligible, discount2 is not + discount1.AudienceType = DiscountAudienceType.AllUsers; + discount1.StartDate = DateTime.UtcNow.AddDays(-1); + discount1.EndDate = DateTime.UtcNow.AddDays(30); + discount2.AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions; + discount2.StartDate = DateTime.UtcNow.AddDays(-1); + discount2.EndDate = DateTime.UtcNow.AddDays(30); sutProvider.GetDependency() - .GetByStripeCouponIdAsync(discount.StripeCouponId) - .Returns(discount); + .GetActiveDiscountsAsync() + .Returns([discount1, discount2]); + + var allUsersFilter = Substitute.For(); + allUsersFilter.IsUserEligible(user, discount1).Returns(DiscountDictionary(true)); + sutProvider.GetDependency() + .GetFilter(DiscountAudienceType.AllUsers) + .Returns(allUsersFilter); + + var restrictedFilter = Substitute.For(); + restrictedFilter.IsUserEligible(user, discount2).Returns(DiscountDictionary(false)); + sutProvider.GetDependency() + .GetFilter(DiscountAudienceType.UserHasNoPreviousSubscriptions) + .Returns(restrictedFilter); // Act - var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(user, discount.StripeCouponId, DiscountTierType.Premium); + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync( + user, [discount1.StripeCouponId, discount2.StripeCouponId], DiscountTierType.Premium); // Assert Assert.False(result); - await sutProvider.GetDependency() - .DidNotReceive() - .DeleteAsync(discount); } }