Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,6 +116,7 @@ public virtual OrganizationSignup ToOrganizationSignup(User user)
},
InitiationPath = InitiationPath,
SkipTrial = SkipTrial,
Coupons = Coupons,
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -36,7 +35,7 @@ public PremiumSubscriptionPurchase ToDomain()
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = AdditionalStorageGb,
Coupon = Coupon
Coupons = Coupons
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
2 changes: 1 addition & 1 deletion src/Core/Billing/Models/Sales/CustomerSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/Core/Billing/Organizations/Models/OrganizationSale.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ private static CustomerSetup GetCustomerSetup(OrganizationSignup signup)
{
var customerSetup = new CustomerSetup
{
Coupon = signup.IsFromProvider
Coupons = signup.IsFromProvider
// TODO: Remove when last of the legacy providers has been migrated.
? StripeConstants.CouponIDs.LegacyMSPDiscount
? [StripeConstants.CouponIDs.LegacyMSPDiscount]
: signup.IsFromSecretsManagerTrial
? StripeConstants.CouponIDs.SecretsManagerStandalone
: null
? [StripeConstants.CouponIDs.SecretsManagerStandalone]
: signup.Coupons
};

if (!signup.PaymentMethodType.HasValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,34 +39,39 @@ public async Task Finalize(OrganizationSale sale)
{
var (organization, customerSetup, subscriptionSetup, owner) = sale;

// Validate coupon and only apply if valid. If invalid, proceed without the discount.
// Validate all provided coupons. Fail fast if any coupon is invalid.
// Validation includes user-specific eligibility checks to ensure the owner has never had premium
// and that this is for a Families subscription.
// Only validate discount if owner is provided (i.e., the user performing the upgrade is an owner).
string? validatedCoupon = null;
if (!string.IsNullOrWhiteSpace(customerSetup?.Coupon) && owner != null)
// Only validate discounts if owner is provided (i.e., the user performing the upgrade is an owner).
var validatedCoupons = new List<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();
}
}

var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
? 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)
{
Expand Down Expand Up @@ -372,7 +377,7 @@ private async Task<Subscription> CreateSubscriptionAsync(
Organization organization,
Customer customer,
SubscriptionSetup subscriptionSetup,
string? coupon)
IReadOnlyList<string> coupons)
{
var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType);

Expand Down Expand Up @@ -435,7 +440,7 @@ private async Task<Subscription> CreateSubscriptionAsync(
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Discounts = !string.IsNullOrWhiteSpace(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }] : null,
Discounts = coupons.Count > 0 ? coupons.Select(c => new SubscriptionDiscountOptions { Coupon = c }).ToList() : null,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,21 @@ public Task<BillingCommandResult<None>> Run(
return new BadRequest("Additional storage must be greater than 0.");
}

// Validate coupon if provided. Return error if invalid to prevent charging more than expected.
string? validatedCoupon = null;
if (!string.IsNullOrWhiteSpace(subscriptionPurchase.Coupon))
// Validate all provided coupons. Fail fast if any coupon is invalid to prevent charging more than expected.
var validatedCoupons = (subscriptionPurchase.Coupons ?? [])
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c.Trim())
.ToList();

if (validatedCoupons.Count > 0)
{
var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, subscriptionPurchase.Coupon.Trim(), DiscountTierType.Premium);
if (!isValid)
var allValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(
user, validatedCoupons, DiscountTierType.Premium);

if (!allValid)
{
return new BadRequest("Discount expired. Please review your cart total and try again");
}
validatedCoupon = subscriptionPurchase.Coupon.Trim();
}

var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
Expand Down Expand Up @@ -127,7 +132,7 @@ public Task<BillingCommandResult<None>> Run(

customer = await ReconcileBillingLocationAsync(customer, subscriptionPurchase.BillingAddress);

var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupon);
var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupons);

subscriptionPurchase.PaymentMethod.Switch(
tokenized =>
Expand Down Expand Up @@ -307,7 +312,7 @@ private async Task<Subscription> CreateSubscriptionAsync(
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage,
string? validatedCoupon)
IReadOnlyList<string> validatedCoupons)
{

var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
Expand Down Expand Up @@ -349,9 +354,11 @@ private async Task<Subscription> CreateSubscriptionAsync(
OffSession = true
};

if (!string.IsNullOrWhiteSpace(validatedCoupon))
if (validatedCoupons.Count > 0)
{
subscriptionCreateOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = validatedCoupon }];
subscriptionCreateOptions.Discounts = validatedCoupons
.Select(c => new SubscriptionDiscountOptions { Coupon = c })
.ToList();
}

var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
Expand Down
24 changes: 16 additions & 8 deletions src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
public record PremiumPurchasePreview
{
public short? AdditionalStorageGb { get; init; }
public string? Coupon { get; init; }
public string[]? Coupons { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
10 changes: 5 additions & 5 deletions src/Core/Billing/Services/ISubscriptionDiscountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,13 @@ public async Task<IEnumerable<DiscountEligibility>> GetEligibleDiscountsAsync(Us
}

/// <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>
Expand All @@ -56,15 +53,4 @@ public async Task<bool> ValidateDiscountEligibilityForUserAsync(User user, strin
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;
}
}
1 change: 1 addition & 0 deletions src/Core/Models/Business/OrganizationSignup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Loading
Loading