Skip to content
14 changes: 10 additions & 4 deletions src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,9 @@ await mailer.SendEmail(new BusinessPlanRenewal2020MigrationMail
PerUserMonthlyPrice = FormatCurrency(perUserMonthly, culture),
IsAnnual = targetPlan.IsAnnual,
TotalPrice = FormatCurrency(total, culture),
DiscountLines = [.. discounts.Select(discount => discount.Display)]
DiscountLines = [.. discounts.Select(discount => discount.Display)],
ProactiveDiscountMonths = discounts
.FirstOrDefault(discount => discount.CouponId == cohort.ProactiveDiscountCouponCode)?.Months ?? 0
}
});
}
Expand Down Expand Up @@ -540,7 +542,7 @@ sourcePlan.SecretsManager is not null &&
return (int)(seats ?? 0);
}

private sealed record Discount(bool IsPercentage, decimal Value, string Display);
private sealed record Discount(bool IsPercentage, decimal Value, string Display, string CouponId, long Months);

private async Task<List<Discount>> ResolveDiscountsAsync(
OrganizationPlanMigrationCohort cohort,
Expand All @@ -553,15 +555,19 @@ private async Task<List<Discount>> ResolveDiscountsAsync(

Discount? MapCoupon(Coupon? coupon, string couponId)
{
var months = coupon?.DurationInMonths ?? 0;

if (coupon?.PercentOff is { } percentOff)
{
return new Discount(IsPercentage: true, Value: percentOff, Display: $"{percentOff}%");
return new Discount(IsPercentage: true, Value: percentOff, Display: $"{percentOff}%",
CouponId: couponId, Months: months);
}

if (coupon?.AmountOff is { } amountOffMinorUnits)
{
var amountOff = amountOffMinorUnits / 100M;
return new Discount(IsPercentage: false, Value: amountOff, Display: FormatCurrency(amountOff, culture));
return new Discount(IsPercentage: false, Value: amountOff, Display: FormatCurrency(amountOff, culture),
CouponId: couponId, Months: months);
}

logger.LogError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
<mj-text font-size="16px" line-height="24px" font-weight="500" padding="5px 15px">
This is a notice that your Bitwarden subscription price will increase to
the current standard rate at your upcoming renewal on
{{ RenewalDate }}:
{{ RenewalDate }}.{{#if ShowProactiveDiscountCopy}} As a long-time
Bitwarden customer, a loyalty discount will apply to your invoices for
the {{ ProactiveDiscountDurationPhrase }}.{{/if}}
</mj-text>
<mj-text padding="10px 15px">
{{#if HasDiscount}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ public class BusinessPlanRenewal2020MigrationMailView : BaseMailView
public string TotalPeriod => IsAnnual ? "year" : "month";
public List<string> DiscountLines { get; set; } = [];
public bool HasDiscount => DiscountLines.Count > 0;

/// <summary>Months the cohort's proactive discount applies for; 0 when there's no month-based discount.</summary>
public long ProactiveDiscountMonths { get; set; }

/// <summary>Show the loyalty-discount copy only when the proactive discount spans a positive number of months.</summary>
public bool ShowProactiveDiscountCopy => ProactiveDiscountMonths > 0;

public string ProactiveDiscountDurationPhrase =>
ProactiveDiscountMonths == 1 ? "next month" : $"next {ProactiveDiscountMonths} months";
}

public class BusinessPlanRenewal2020MigrationMail : BaseMail<BusinessPlanRenewal2020MigrationMailView>
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Thank you for being a long-time Bitwarden user. Your account has been on the original legacy rate since the early days of your Bitwarden plan.

This is a notice that your Bitwarden subscription price will increase to the current standard rate at your upcoming renewal on {{RenewalDate}}:
This is a notice that your Bitwarden subscription price will increase to the current standard rate at your upcoming renewal on {{RenewalDate}}.{{#if ShowProactiveDiscountCopy}} As a long-time Bitwarden customer, a loyalty discount will apply to your invoices for the {{ProactiveDiscountDurationPhrase}}.{{/if}}

{{Seats}} users x {{PerUserMonthlyPrice}} / month
{{#each DiscountLines}}
Expand Down
65 changes: 64 additions & 1 deletion test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4118,6 +4118,7 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoCoupon_SendsPriceOn
await _mailer.Received(1).SendEmail(Arg.Is<BusinessPlanRenewal2020MigrationMail>(mail =>
mail.ToEmails.Contains("org@example.com") &&
!mail.View.HasDiscount &&
!mail.View.ShowProactiveDiscountCopy &&
mail.View.IsAnnual &&
mail.View.Seats == 320 &&
mail.View.PerUserMonthlyPrice == "$6" &&
Expand Down Expand Up @@ -4178,7 +4179,69 @@ public async Task HandleAsync_WhenBusinessTier_AndCouponUnresolvable_SendsPriceO
// Assert β€” the email is still sent, price-only, and the StripeException does not propagate.
await _mailer.Received(1).SendEmail(Arg.Is<BusinessPlanRenewal2020MigrationMail>(mail =>
mail.ToEmails.Contains("org@example.com") &&
!mail.View.HasDiscount));
!mail.View.HasDiscount &&
!mail.View.ShowProactiveDiscountCopy));
}

[Theory]
[InlineData("repeating", 12L, 12, true)]
[InlineData("once", null, 0, false)]
[InlineData("forever", null, 0, false)]
public async Task HandleAsync_WhenBusinessTier_SetsProactiveDiscountMonths_FromCouponDuration(
string duration, long? durationInMonths, int expectedMonths, bool expectedShow)
{
// Arrange β€” a proactive coupon whose Stripe duration drives the loyalty-discount copy. Only a
// "repeating" coupon has a finite month span; "once"/"forever" suppress the copy.
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually2020
};
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
var enterprisePlan = new EnterprisePlan(isAnnual: true);
var cohortId = Guid.NewGuid();
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = _organizationId,
CohortId = cohortId,
ScheduledDate = null
};
var cohort = new OrganizationPlanMigrationCohort
{
Id = cohortId,
Name = "enterprise-2020-annual",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
ProactiveDiscountCouponCode = "loyalty",
IsActive = true
};

_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeAdapter.GetCustomerAsync(customer.Id, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020).Returns(enterprise2020Plan);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterprisePlan);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
_priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>())
.Returns(true);
_stripeAdapter.GetCouponAsync("loyalty", Arg.Any<CouponGetOptions>())
.Returns(new Coupon { PercentOff = 20, Duration = duration, DurationInMonths = durationInMonths });

// Act
await _sut.HandleAsync(parsedEvent);

// Assert
await _mailer.Received(1).SendEmail(Arg.Is<BusinessPlanRenewal2020MigrationMail>(mail =>
mail.View.ProactiveDiscountMonths == expectedMonths &&
mail.View.ShowProactiveDiscountCopy == expectedShow));
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,46 @@ public void TotalPeriod_FollowsCadence(bool isAnnual, string expected)
Assert.Equal(expected, view.TotalPeriod);
}

[Fact]
public void ShowProactiveDiscountCopy_IsFalse_WhenNoMonths()
{
var view = BuildView(proactiveDiscountMonths: 0);

Assert.False(view.ShowProactiveDiscountCopy);
}

[Theory]
[InlineData(1)]
[InlineData(12)]
public void ShowProactiveDiscountCopy_IsTrue_WhenPositiveMonths(int months)
{
var view = BuildView(proactiveDiscountMonths: months);

Assert.True(view.ShowProactiveDiscountCopy);
}

[Theory]
[InlineData(1, "next month")]
[InlineData(12, "next 12 months")]
public void ProactiveDiscountDurationPhrase_FollowsMonthCount(int months, string expected)
{
var view = BuildView(proactiveDiscountMonths: months);

Assert.Equal(expected, view.ProactiveDiscountDurationPhrase);
}

private static BusinessPlanRenewal2020MigrationMailView BuildView(
List<string>? discountLines = null,
bool isAnnual = true) =>
bool isAnnual = true,
int proactiveDiscountMonths = 0) =>
new()
{
RenewalDate = "June 12, 2026",
Seats = 320,
PerUserMonthlyPrice = "$6.00",
IsAnnual = isAnnual,
TotalPrice = "$23,040.00",
DiscountLines = discountLines ?? []
DiscountLines = discountLines ?? [],
ProactiveDiscountMonths = proactiveDiscountMonths
};
}
Loading