Skip to content
Merged
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
20 changes: 10 additions & 10 deletions src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,29 @@ public record BitwardenDiscount
/// </summary>
public required decimal Value { get; init; }

public static implicit operator BitwardenDiscount(Discount? discount)
public static implicit operator BitwardenDiscount?(Coupon? coupon)
{
if (discount is not
{
Coupon.Valid: true
})
if (coupon is not { Valid: true })
{
return null!;
return null;
}

return discount.Coupon switch
return coupon switch
{
{ AmountOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.AmountOff,
Value = discount.Coupon.AmountOff.Value
Value = coupon.AmountOff.Value
},
{ PercentOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.PercentOff,
Value = discount.Coupon.PercentOff.Value
Value = coupon.PercentOff.Value
},
_ => null!
_ => null
};
}

public static implicit operator BitwardenDiscount?(Discount? discount) =>
discount?.Coupon;
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ private async Task<Cart> GetPremiumCartAsync(

var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);

var cartDiscount = cartLevelDiscount ?? await GetSchedulePhase2DiscountAsync(subscription);

var availablePlan = plans.First(plan => plan.Available);
var onCurrentPricing = passwordManagerSeatsItem.Price.Id == availablePlan.Seat.StripePriceId;

Expand Down Expand Up @@ -152,7 +154,7 @@ private async Task<Cart> GetPremiumCartAsync(
AdditionalStorage = additionalStorage
},
Cadence = PlanCadenceType.Annually,
Discount = cartLevelDiscount,
Discount = cartDiscount,
EstimatedTax = estimatedTax
};
}
Expand Down Expand Up @@ -244,6 +246,37 @@ private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDisco
return (cartLevel.FirstOrDefault(), productLevel);
}

private async Task<BitwardenDiscount?> GetSchedulePhase2DiscountAsync(Subscription subscription)
{
if (string.IsNullOrEmpty(subscription.ScheduleId))
{
return null;
}

try
{
var schedule = await stripeAdapter.GetSubscriptionScheduleAsync(subscription.ScheduleId,
new SubscriptionScheduleGetOptions
{
Expand = ["phases.discounts.coupon"]
});

if (schedule.Status != SubscriptionScheduleStatus.Active || schedule.Phases.Count < 2)
{
return null;
}

return schedule.Phases[1].Discounts?.FirstOrDefault()?.Coupon;
}
catch (StripeException stripeException)
{
logger.LogError(stripeException,
"Failed to retrieve subscription schedule ({ScheduleID}) for discount resolution",
subscription.ScheduleId);
return null;
}
}

private async Task<Subscription?> FetchSubscriptionAsync(User user)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,171 @@ public async Task Run_UserOnCurrentPricing_ReturnsCostFromSubscriptionItem()
Assert.Equal(19.80m, result.Cart.PasswordManager.Seats.Cost);
}

[Fact]
public async Task Run_WithSchedulePhase2Discount_IncludesDiscountInCart()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();
var schedule = CreateSubscriptionSchedule(percentOff: 30);

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
.Returns(schedule);

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.NotNull(result.Cart.Discount);
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type);
Assert.Equal(30, result.Cart.Discount.Value);
}

[Fact]
public async Task Run_WithCartLevelDiscountAndScheduleDiscount_PrefersCartLevelDiscount()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.Customer.Discount = CreateDiscount(discountType: "cart");
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.NotNull(result.Cart.Discount);
Assert.Equal(20, result.Cart.Discount.Value);
await _stripeAdapter.DidNotReceive()
.GetSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleGetOptions>());
}

[Fact]
public async Task Run_WithScheduleButNoPhase2Discount_ReturnsNoDiscount()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();
var schedule = CreateSubscriptionSchedule(includePhase2: false);

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
.Returns(schedule);

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.Null(result.Cart.Discount);
}

[Fact]
public async Task Run_WithScheduleStripeException_ReturnsNoDiscount()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
.ThrowsAsync(new StripeException());

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.Null(result.Cart.Discount);
}

[Fact]
public async Task Run_WithSchedulePhase2AmountOffDiscount_IncludesAmountOffDiscountInCart()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();
var schedule = CreateSubscriptionSchedule(amountOff: 500);

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
.Returns(schedule);

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.NotNull(result.Cart.Discount);
Assert.Equal(BitwardenDiscountType.AmountOff, result.Cart.Discount.Type);
Assert.Equal(500, result.Cart.Discount.Value);
}

[Fact]
public async Task Run_WithCompletedSchedule_ReturnsNoDiscount()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();
var schedule = CreateSubscriptionSchedule(percentOff: 30, status: "completed");

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
.Returns(schedule);

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.Null(result.Cart.Discount);
}

[Fact]
public async Task Run_WithSchedulePhase2InvalidCoupon_ReturnsNoDiscount()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.ScheduleId = "sub_sched_test";
var premiumPlans = CreatePremiumPlans();
var schedule = CreateSubscriptionSchedule(percentOff: 30, validCoupon: false);

_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
.Returns(schedule);

var result = await _query.Run(user);

Assert.NotNull(result);
Assert.Null(result.Cart.Discount);
}

#region Helper Methods

private static User CreateUser()
Expand Down Expand Up @@ -727,6 +892,37 @@ private static Invoice CreateInvoicePreview(long totalTax = 0)
};
}

private static SubscriptionSchedule CreateSubscriptionSchedule(
bool includePhase2 = true,
decimal? percentOff = null,
long? amountOff = null,
string status = StripeConstants.SubscriptionScheduleStatus.Active,
bool validCoupon = true)
{
var phases = new List<SubscriptionSchedulePhase>
{
new() { Discounts = [] }
};

if (includePhase2)
{
phases.Add(new SubscriptionSchedulePhase
{
Discounts = [new SubscriptionSchedulePhaseDiscount
{
Coupon = new Coupon { Valid = validCoupon, PercentOff = percentOff, AmountOff = amountOff }
}]
});
}

return new SubscriptionSchedule
{
Id = "sub_sched_test",
Status = status,
Phases = phases
};
}

private static Discount CreateDiscount(string discountType = "cart", string? productId = null)
{
var coupon = new Coupon
Expand Down
Loading