[PM-30101] add multiple coupon support to server preview/purchase (#7229)

* [PM-30101] add multiple coupon support to server preview/purchase

* pr feedback
This commit is contained in:
Kyle Denney
2026-03-19 09:07:49 -05:00
committed by GitHub
parent 3d99dbea99
commit 2efacd596d
22 changed files with 899 additions and 188 deletions

View File

@@ -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 class OrganizationCreateRequestModel : IValidatableObject
},
InitiationPath = InitiationPath,
SkipTrial = SkipTrial,
Coupons = Coupons,
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
};

View File

@@ -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<ValidationResult> Validate(ValidationContext validationContext)

View File

@@ -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 class PremiumCloudHostedSubscriptionRequest : IValidatableObject
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = AdditionalStorageGb,
Coupon = Coupon
Coupons = Coupons
};
}

View File

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

View File

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

View File

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

View File

@@ -63,12 +63,12 @@ public class OrganizationSale
{
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)

View File

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

View File

@@ -39,26 +39,31 @@ public class OrganizationBillingService(
{
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<string>();
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 class OrganizationBillingService(
? 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 @@ public class OrganizationBillingService(
Organization organization,
Customer customer,
SubscriptionSetup subscriptionSetup,
string? coupon)
IReadOnlyList<string> coupons)
{
var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType);
@@ -435,7 +440,7 @@ public class OrganizationBillingService(
{
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<string, string>
{

View File

@@ -83,16 +83,21 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
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 class CreatePremiumCloudHostedSubscriptionCommand(
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 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage,
string? validatedCoupon)
IReadOnlyList<string> validatedCoupons)
{
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
@@ -349,9 +354,11 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
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);

View File

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

View File

@@ -3,5 +3,5 @@
public record PremiumPurchasePreview
{
public short? AdditionalStorageGb { get; init; }
public string? Coupon { get; init; }
public string[]? Coupons { get; init; }
}

View File

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

View File

@@ -17,12 +17,12 @@ public interface ISubscriptionDiscountService
Task<IEnumerable<DiscountEligibility>> GetEligibleDiscountsAsync(User user);
/// <summary>
/// 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.
/// </summary>
/// <param name="user">The user to validate eligibility for.</param>
/// <param name="coupon">The Stripe coupon ID to validate.</param>
/// <param name="couponIds">The Stripe coupon IDs to validate.</param>
/// <param name="tierType">The product tier the user intends to subscribe to.</param>
/// <returns><see langword="true"/> if the discount exists and the user is eligible for the given tier; otherwise <see langword="false"/>.</returns>
Task<bool> ValidateDiscountEligibilityForUserAsync(User user, string coupon, DiscountTierType tierType);
/// <returns><see langword="true"/> if all coupons are found in the user's eligible discounts and tier eligibility is <see langword="true"/> for <paramref name="tierType"/>; otherwise <see langword="false"/>.</returns>
Task<bool> ValidateDiscountEligibilityForUserAsync(User user, IReadOnlyList<string> couponIds, DiscountTierType tierType);
}

View File

@@ -34,16 +34,13 @@ public class SubscriptionDiscountService(
}
/// <inheritdoc />
public async Task<bool> ValidateDiscountEligibilityForUserAsync(User user, string coupon, DiscountTierType tierType)
public async Task<bool> ValidateDiscountEligibilityForUserAsync(User user, IReadOnlyList<string> 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]);
}
/// <summary>
@@ -56,15 +53,4 @@ public class SubscriptionDiscountService(
var filter = discountAudienceFilterFactory.GetFilter(discount.AudienceType);
return filter is not null ? await filter.IsUserEligible(user, discount) : null;
}
/// <summary>
/// Checks if a discount is currently active based on its start and end dates.
/// </summary>
/// <param name="discount">The discount to check.</param>
/// <returns><see langword="true"/> if the current time is within the discount's valid date range; otherwise, <see langword="false"/>.</returns>
private static bool IsDiscountActive(SubscriptionDiscount discount)
{
var now = DateTime.UtcNow;
return now >= discount.StartDate && now <= discount.EndDate;
}
}

View File

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

View File

@@ -438,7 +438,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = "TEST_COUPON_20"
Coupons = ["TEST_COUPON_20"]
};
var billingAddress = new BillingAddress
@@ -497,7 +497,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalServiceAccounts = 2,
Standalone = false
},
Coupon = "ENTERPRISE_DISCOUNT_15"
Coupons = ["ENTERPRISE_DISCOUNT_15"]
};
var billingAddress = new BillingAddress
@@ -556,7 +556,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = true
},
Coupon = "TEST_COUPON_IGNORED"
Coupons = ["TEST_COUPON_IGNORED"]
};
var billingAddress = new BillingAddress
@@ -615,7 +615,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalServiceAccounts = 0,
Standalone = true
},
Coupon = "USER_COUPON_IGNORED"
Coupons = ["USER_COUPON_IGNORED"]
};
var billingAddress = new BillingAddress
@@ -672,7 +672,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = ""
Coupons = null
};
var billingAddress = new BillingAddress
@@ -777,7 +777,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = " "
Coupons = [" "]
};
var billingAddress = new BillingAddress
@@ -831,7 +831,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = " TEST_COUPON_20 "
Coupons = [" TEST_COUPON_20 "]
};
var billingAddress = new BillingAddress
@@ -887,7 +887,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = longCoupon
Coupons = [longCoupon]
};
var billingAddress = new BillingAddress
@@ -943,7 +943,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = specialCoupon
Coupons = [specialCoupon]
};
var billingAddress = new BillingAddress
@@ -999,7 +999,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = unicodeCoupon
Coupons = [unicodeCoupon]
};
var billingAddress = new BillingAddress
@@ -2094,7 +2094,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = "VALID_FAMILIES_DISCOUNT"
Coupons = ["VALID_FAMILIES_DISCOUNT"]
};
var billingAddress = new BillingAddress
@@ -2108,7 +2108,7 @@ public class PreviewOrganizationTaxCommandTests
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"VALID_FAMILIES_DISCOUNT",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_FAMILIES_DISCOUNT" })),
DiscountTierType.Families).Returns(true);
var invoice = new Invoice
@@ -2128,7 +2128,7 @@ public class PreviewOrganizationTaxCommandTests
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(
_user,
"VALID_FAMILIES_DISCOUNT",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_FAMILIES_DISCOUNT" })),
DiscountTierType.Families);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
@@ -2150,7 +2150,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = "INVALID_COUPON"
Coupons = ["INVALID_COUPON"]
};
var billingAddress = new BillingAddress
@@ -2164,7 +2164,7 @@ public class PreviewOrganizationTaxCommandTests
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"INVALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })),
DiscountTierType.Families).Returns(false);
var invoice = new Invoice
@@ -2184,7 +2184,7 @@ public class PreviewOrganizationTaxCommandTests
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(
_user,
"INVALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })),
DiscountTierType.Families);
// Verify invalid coupon is silently ignored (no discount applied)
@@ -2213,7 +2213,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = "TEAMS_COUPON"
Coupons = ["TEAMS_COUPON"]
};
var billingAddress = new BillingAddress
@@ -2243,7 +2243,7 @@ public class PreviewOrganizationTaxCommandTests
// Verify coupon validation was NOT called for Teams (only Families plans use coupons)
await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync(
Arg.Any<User>(),
Arg.Any<string>(),
Arg.Any<IReadOnlyList<string>>(),
Arg.Any<DiscountTierType>());
// Verify coupon is ignored for Teams plans (no discounts applied)
@@ -2272,7 +2272,7 @@ public class PreviewOrganizationTaxCommandTests
AdditionalStorage = 0,
Sponsored = false
},
Coupon = "ENTERPRISE_COUPON"
Coupons = ["ENTERPRISE_COUPON"]
};
var billingAddress = new BillingAddress
@@ -2302,7 +2302,7 @@ public class PreviewOrganizationTaxCommandTests
// Verify coupon validation was NOT called for Enterprise (only Families plans use coupons)
await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync(
Arg.Any<User>(),
Arg.Any<string>(),
Arg.Any<IReadOnlyList<string>>(),
Arg.Any<DiscountTierType>());
// Verify coupon is ignored for Enterprise plans (no discounts applied)
@@ -2319,4 +2319,141 @@ public class PreviewOrganizationTaxCommandTests
}
#endregion
#region Multi-coupon support
[Fact]
public async Task Run_WithMultipleValidCoupons_AppliesBothToInvoicePreview()
{
var purchase = new OrganizationSubscriptionPurchase
{
Tier = ProductTierType.Families,
Cadence = PlanCadenceType.Annually,
PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections
{
Seats = 6,
AdditionalStorage = 0,
Sponsored = false
},
Coupons = ["COUPON_ONE", "COUPON_TWO"]
};
var billingAddress = new BillingAddress { Country = "US", PostalCode = "12345" };
var plan = new FamiliesPlan();
_pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan);
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
Arg.Is<IReadOnlyList<string>>(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<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, purchase, billingAddress);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<InvoiceCreatePreviewOptions>()).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<User>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<DiscountTierType>());
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<IReadOnlyList<string>>(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<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, purchase, billingAddress);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Discounts == null || options.Discounts.Count == 0));
}
#endregion
}

View File

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

View File

@@ -81,14 +81,14 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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 class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var mockCustomer = Substitute.For<StripeCustomer>();
@@ -237,7 +237,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var mockCustomer = Substitute.For<StripeCustomer>();
@@ -301,7 +301,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = additionalStorage,
Coupon = null
Coupons = null
};
var mockCustomer = Substitute.For<StripeCustomer>();
@@ -363,7 +363,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var mockCustomer = Substitute.For<StripeCustomer>();
@@ -422,7 +422,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var mockCustomer = Substitute.For<StripeCustomer>();
@@ -531,7 +531,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
// Act
@@ -596,7 +596,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
// Act
@@ -660,7 +660,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
// Act
@@ -721,7 +721,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
// Act
@@ -754,7 +754,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
// Act
@@ -820,7 +820,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = additionalStorage,
Coupon = null
Coupons = null
};
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
@@ -1129,11 +1129,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_COUPON" })), DiscountTierType.Premium)
.Returns(true);
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
@@ -1145,7 +1146,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
// Assert
Assert.True(result.IsT0);
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "VALID_COUPON", DiscountTierType.Premium);
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(
user, Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_COUPON" })), DiscountTierType.Premium);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(opts =>
opts.Discounts != null &&
opts.Discounts.Count == 1 &&
@@ -1167,9 +1169,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Premium)
.Returns(false);
// Act
@@ -1179,7 +1182,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })), DiscountTierType.Premium);
await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(Arg.Any<Guid>());
@@ -1199,10 +1203,11 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY_COUPON" })), DiscountTierType.Premium)
.Returns(false);
// Act
@@ -1212,7 +1217,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY_COUPON" })), DiscountTierType.Premium);
await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(Arg.Any<Guid>());
@@ -1231,11 +1237,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "WHITESPACE_COUPON" })), DiscountTierType.Premium)
.Returns(true);
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
@@ -1248,7 +1255,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
// 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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "WHITESPACE_COUPON" })), DiscountTierType.Premium);
// Verify the coupon was trimmed before passing to Stripe
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(opts =>
opts.Discounts != null &&
@@ -1256,4 +1264,130 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
opts.Discounts[0].Coupon == "WHITESPACE_COUPON"));
}
[Theory, BitAutoData]
public async Task Run_WithMultipleValidCoupons_CreatesSubscriptionWithAllCoupons(
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: ["COUPON_ONE", "COUPON_TWO"]);
var mockCustomer = CreateMockCustomer();
var mockSubscription = CreateMockActiveSubscription();
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
user, Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })), DiscountTierType.Premium).Returns(true);
_stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
// Act
var result = await _command.Run(user, subscriptionPurchase);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(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<IReadOnlyList<string>>(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<SubscriptionCreateOptions>());
}
[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<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
// Act
var result = await _command.Run(user, subscriptionPurchase);
// Assert
Assert.True(result.IsT0);
await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync(
Arg.Any<User>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<DiscountTierType>());
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(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<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(mockCustomer);
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
// Act
var result = await _command.Run(user, subscriptionPurchase);
// Assert
Assert.True(result.IsT0);
await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync(
Arg.Any<User>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<DiscountTierType>());
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(opts =>
opts.Discounts == null));
}
}

View File

@@ -44,12 +44,12 @@ public class 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 class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -124,7 +124,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 5,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -166,7 +166,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -206,7 +206,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 20,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -248,7 +248,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 10,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -290,7 +290,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -330,7 +330,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = -5,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -371,7 +371,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var result = await _command.Run(_user, preview, billingAddress);
@@ -386,11 +386,11 @@ public class PreviewPremiumTaxCommandTests
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<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_COUPON_CODE" })),
DiscountTierType.Premium).Returns(true);
var invoice = new Invoice
@@ -425,11 +425,11 @@ public class PreviewPremiumTaxCommandTests
public async Task Run_WithCouponAndStorage_IncludesBothInInvoicePreview()
{
var billingAddress = CreateBillingAddress(country: "CA", postalCode: "K1A 0A6");
var preview = CreatePreview(additionalStorageGb: 5, coupon: "STORAGE_DISCOUNT");
var preview = CreatePreview(additionalStorageGb: 5, coupons: ["STORAGE_DISCOUNT"]);
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"STORAGE_DISCOUNT",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "STORAGE_DISCOUNT" })),
DiscountTierType.Premium).Returns(true);
var invoice = new Invoice
@@ -466,11 +466,11 @@ public class PreviewPremiumTaxCommandTests
public async Task Run_WithCouponWhitespace_TrimsCouponCode()
{
var billingAddress = CreateBillingAddress(country: "GB", postalCode: "SW1A 1AA");
var preview = CreatePreview(coupon: " WHITESPACE_COUPON ");
var preview = CreatePreview(coupons: [" WHITESPACE_COUPON "]);
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"WHITESPACE_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "WHITESPACE_COUPON" })),
DiscountTierType.Premium).Returns(true);
var invoice = new Invoice
@@ -513,7 +513,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 0,
Coupon = null
Coupons = null
};
var invoice = new Invoice
@@ -554,7 +554,7 @@ public class PreviewPremiumTaxCommandTests
var preview = new PremiumPurchasePreview
{
AdditionalStorageGb = 0,
Coupon = ""
Coupons = [""]
};
var invoice = new Invoice
@@ -587,11 +587,11 @@ public class PreviewPremiumTaxCommandTests
public async Task Run_WithValidCoupon_ValidatesCouponAndAppliesDiscount()
{
var billingAddress = CreateBillingAddress();
var preview = CreatePreview(coupon: "VALID_DISCOUNT");
var preview = CreatePreview(coupons: ["VALID_DISCOUNT"]);
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"VALID_DISCOUNT",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_DISCOUNT" })),
DiscountTierType.Premium).Returns(true);
var invoice = new Invoice
@@ -611,7 +611,7 @@ public class PreviewPremiumTaxCommandTests
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(
_user,
"VALID_DISCOUNT",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_DISCOUNT" })),
DiscountTierType.Premium);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
@@ -624,11 +624,11 @@ public class PreviewPremiumTaxCommandTests
public async Task Run_WithInvalidCoupon_IgnoresCouponAndProceeds()
{
var billingAddress = CreateBillingAddress();
var preview = CreatePreview(coupon: "INVALID_COUPON");
var preview = CreatePreview(coupons: ["INVALID_COUPON"]);
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"INVALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })),
DiscountTierType.Premium).Returns(false);
var invoice = new Invoice
@@ -648,7 +648,7 @@ public class PreviewPremiumTaxCommandTests
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(
_user,
"INVALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })),
DiscountTierType.Premium);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
@@ -659,12 +659,12 @@ public class PreviewPremiumTaxCommandTests
public async Task Run_WithCouponForUserWithPreviousSubscription_IgnoresCouponAndProceeds()
{
var billingAddress = CreateBillingAddress();
var preview = CreatePreview(coupon: "NEW_USER_ONLY");
var preview = CreatePreview(coupons: ["NEW_USER_ONLY"]);
// User has previous subscription, so validation fails
_subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
_user,
"NEW_USER_ONLY",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY" })),
DiscountTierType.Premium).Returns(false);
var invoice = new Invoice
@@ -684,10 +684,117 @@ public class PreviewPremiumTaxCommandTests
await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(
_user,
"NEW_USER_ONLY",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "NEW_USER_ONLY" })),
DiscountTierType.Premium);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<IReadOnlyList<string>>(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<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, preview, billingAddress);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<IReadOnlyList<string>>(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<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, preview, billingAddress);
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, preview, billingAddress);
Assert.True(result.IsT0);
await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync(
Arg.Any<User>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<DiscountTierType>());
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(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<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(_user, preview, billingAddress);
Assert.True(result.IsT0);
await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync(
Arg.Any<User>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<DiscountTierType>());
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.Discounts == null));
}
}

View File

@@ -273,7 +273,7 @@ public class OrganizationBillingServiceTests
var customerSetup = new CustomerSetup
{
Coupon = "VALID_COUPON"
Coupons = ["VALID_COUPON"]
};
var subscriptionSetup = new SubscriptionSetup
@@ -304,7 +304,7 @@ public class OrganizationBillingServiceTests
sutProvider.GetDependency<ISubscriptionDiscountService>()
.ValidateDiscountEligibilityForUserAsync(
owner,
"VALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_COUPON" })),
DiscountTierType.Families)
.Returns(true);
@@ -342,7 +342,7 @@ public class OrganizationBillingServiceTests
.Received(1)
.ValidateDiscountEligibilityForUserAsync(
owner,
"VALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_COUPON" })),
DiscountTierType.Families);
await sutProvider.GetDependency<IStripeAdapter>()
@@ -365,7 +365,7 @@ public class OrganizationBillingServiceTests
var customerSetup = new CustomerSetup
{
Coupon = "INVALID_COUPON"
Coupons = ["INVALID_COUPON"]
};
var subscriptionSetup = new SubscriptionSetup
@@ -397,7 +397,7 @@ public class OrganizationBillingServiceTests
sutProvider.GetDependency<ISubscriptionDiscountService>()
.ValidateDiscountEligibilityForUserAsync(
owner,
"INVALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })),
DiscountTierType.Families)
.Returns(false);
@@ -423,7 +423,7 @@ public class OrganizationBillingServiceTests
.Received(1)
.ValidateDiscountEligibilityForUserAsync(
owner,
"INVALID_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "INVALID_COUPON" })),
DiscountTierType.Families);
// Verify subscription was NOT created
@@ -446,7 +446,7 @@ public class OrganizationBillingServiceTests
var customerSetup = new CustomerSetup
{
Coupon = null
Coupons = null
};
var subscriptionSetup = new SubscriptionSetup
@@ -506,7 +506,7 @@ public class OrganizationBillingServiceTests
// Assert - Validation should NOT be called
await sutProvider.GetDependency<ISubscriptionDiscountService>()
.DidNotReceive()
.ValidateDiscountEligibilityForUserAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<DiscountTierType>());
.ValidateDiscountEligibilityForUserAsync(Arg.Any<User>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<DiscountTierType>());
// Subscription should still be created
await sutProvider.GetDependency<IStripeAdapter>()
@@ -528,7 +528,7 @@ public class OrganizationBillingServiceTests
var customerSetup = new CustomerSetup
{
Coupon = "EXPIRED_COUPON"
Coupons = ["EXPIRED_COUPON"]
};
var subscriptionSetup = new SubscriptionSetup
@@ -560,7 +560,7 @@ public class OrganizationBillingServiceTests
sutProvider.GetDependency<ISubscriptionDiscountService>()
.ValidateDiscountEligibilityForUserAsync(
owner,
"EXPIRED_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "EXPIRED_COUPON" })),
DiscountTierType.Families)
.Returns(false);
@@ -586,7 +586,7 @@ public class OrganizationBillingServiceTests
.Received(1)
.ValidateDiscountEligibilityForUserAsync(
owner,
"EXPIRED_COUPON",
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "EXPIRED_COUPON" })),
DiscountTierType.Families);
// Verify subscription was NOT created
@@ -595,6 +595,174 @@ public class OrganizationBillingServiceTests
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
public async Task Finalize_WithMultipleValidCoupons_AppliesAllToSubscription(
Organization organization,
User owner,
SutProvider<OrganizationBillingService> 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<IPricingClient>()
.GetPlanOrThrow(PlanType.FamiliesAnnually)
.Returns(plan);
sutProvider.GetDependency<ISubscriptionDiscountService>()
.ValidateDiscountEligibilityForUserAsync(
owner,
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })),
DiscountTierType.Families)
.Returns(true);
sutProvider.GetDependency<IHasPaymentMethodQuery>()
.Run(organization)
.Returns(true);
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
sutProvider.GetDependency<IStripeAdapter>()
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>())
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Active
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert
await sutProvider.GetDependency<ISubscriptionDiscountService>()
.Received(1)
.ValidateDiscountEligibilityForUserAsync(
owner,
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "COUPON_ONE", "COUPON_TWO" })),
DiscountTierType.Families);
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(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<OrganizationBillingService> 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<IPricingClient>()
.GetPlanOrThrow(PlanType.FamiliesAnnually)
.Returns(plan);
sutProvider.GetDependency<ISubscriptionDiscountService>()
.ValidateDiscountEligibilityForUserAsync(
owner,
Arg.Is<IReadOnlyList<string>>(a => a.SequenceEqual(new[] { "VALID_COUPON", "INVALID_COUPON" })),
DiscountTierType.Families)
.Returns(false);
sutProvider.GetDependency<IHasPaymentMethodQuery>()
.Run(organization)
.Returns(true);
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<IStripeAdapter>()
.DidNotReceive()
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
public async Task Finalize_BusinessWithExemptStatus_DoesNotUpdateTaxExemption(
Organization organization,

View File

@@ -177,24 +177,24 @@ public class SubscriptionDiscountServiceTests
}
[Theory, BitAutoData]
public async Task ValidateDiscountEligibilityForUserAsync_CouponNotFound_ReturnsFalse(
public async Task ValidateDiscountEligibilityForUserAsync_CouponNotInEligibleDiscounts_ReturnsFalse(
User user,
SutProvider<SubscriptionDiscountService> sutProvider)
{
// Arrange
// Arrange — no active discounts, so the requested coupon won't be found
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.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<SubscriptionDiscountService> sutProvider)
@@ -205,8 +205,8 @@ public class SubscriptionDiscountServiceTests
discount.EndDate = DateTime.UtcNow.AddDays(30);
sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(discount.StripeCouponId)
.Returns(discount);
.GetActiveDiscountsAsync()
.Returns([discount]);
var filter = Substitute.For<IDiscountAudienceFilter>();
filter.IsUserEligible(user, discount).Returns(DiscountDictionary(true));
@@ -215,26 +215,27 @@ public class SubscriptionDiscountServiceTests
.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<SubscriptionDiscountService> 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<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(discount.StripeCouponId)
.Returns(discount);
.GetActiveDiscountsAsync()
.Returns([discount]);
var filter = Substitute.For<IDiscountAudienceFilter>();
filter.IsUserEligible(user, discount).Returns(DiscountDictionary(false));
@@ -243,7 +244,8 @@ public class SubscriptionDiscountServiceTests
.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 class SubscriptionDiscountServiceTests
SubscriptionDiscount discount,
SutProvider<SubscriptionDiscountService> 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<ISubscriptionDiscountRepository>()
.GetByStripeCouponIdAsync(discount.StripeCouponId)
.Returns(discount);
.GetActiveDiscountsAsync()
.Returns([]);
// 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.False(result);
}
[Theory, BitAutoData]
public async Task ValidateDiscountEligibilityForUserAsync_MultipleCoupons_AllEligible_ReturnsTrue(
User user,
SubscriptionDiscount discount1,
SubscriptionDiscount discount2,
SutProvider<SubscriptionDiscountService> 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<ISubscriptionDiscountRepository>()
.GetActiveDiscountsAsync()
.Returns([discount1, discount2]);
var filter = Substitute.For<IDiscountAudienceFilter>();
filter.IsUserEligible(user, discount1).Returns(DiscountDictionary(true));
filter.IsUserEligible(user, discount2).Returns(DiscountDictionary(true));
sutProvider.GetDependency<IDiscountAudienceFilterFactory>()
.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<SubscriptionDiscountService> 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<ISubscriptionDiscountRepository>()
.GetActiveDiscountsAsync()
.Returns([discount1, discount2]);
var allUsersFilter = Substitute.For<IDiscountAudienceFilter>();
allUsersFilter.IsUserEligible(user, discount1).Returns(DiscountDictionary(true));
sutProvider.GetDependency<IDiscountAudienceFilterFactory>()
.GetFilter(DiscountAudienceType.AllUsers)
.Returns(allUsersFilter);
var restrictedFilter = Substitute.For<IDiscountAudienceFilter>();
restrictedFilter.IsUserEligible(user, discount2).Returns(DiscountDictionary(false));
sutProvider.GetDependency<IDiscountAudienceFilterFactory>()
.GetFilter(DiscountAudienceType.UserHasNoPreviousSubscriptions)
.Returns(restrictedFilter);
// Act
var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(
user, [discount1.StripeCouponId, discount2.StripeCouponId], DiscountTierType.Premium);
// Assert
Assert.False(result);
await sutProvider.GetDependency<ISubscriptionDiscountRepository>()
.DidNotReceive()
.DeleteAsync(discount);
}
}