mirror of
https://github.com/bitwarden/server.git
synced 2026-04-28 09:11:06 -05:00
[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:
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
public record PremiumPurchasePreview
|
||||
{
|
||||
public short? AdditionalStorageGb { get; init; }
|
||||
public string? Coupon { get; init; }
|
||||
public string[]? Coupons { get; init; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user