From a5e20c4fb9e3d03b0c0ec9d767f9e9253149c927 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:52 -0400 Subject: [PATCH 1/7] feat(billing): add proactive discount details to renewal mail view --- .../BusinessPlanRenewal2020MigrationMailView.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.cs b/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.cs index 0b5b1fe02706..79c55b60b7a6 100644 --- a/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.cs @@ -12,6 +12,15 @@ public class BusinessPlanRenewal2020MigrationMailView : BaseMailView public string TotalPeriod => IsAnnual ? "year" : "month"; public List DiscountLines { get; set; } = []; public bool HasDiscount => DiscountLines.Count > 0; + + /// Months the cohort's proactive discount applies for; 0 when there's no month-based discount. + public long ProactiveDiscountMonths { get; set; } + + /// Show the loyalty-discount copy only when the proactive discount spans a positive number of months. + public bool ShowProactiveDiscountCopy => ProactiveDiscountMonths > 0; + + public string ProactiveDiscountDurationPhrase => + ProactiveDiscountMonths == 1 ? "next month" : $"next {ProactiveDiscountMonths} months"; } public class BusinessPlanRenewal2020MigrationMail : BaseMail From e01897a5ea3278f5af99672afe1079945d0fbe0c Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:52 -0400 Subject: [PATCH 2/7] test(billing): add unit tests for proactive discount mail view properties --- ...ssPlanRenewal2020MigrationMailViewTests.cs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/test/Core.Test/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailViewTests.cs b/test/Core.Test/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailViewTests.cs index f67c6f7ddbf1..8638825501d6 100644 --- a/test/Core.Test/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailViewTests.cs +++ b/test/Core.Test/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailViewTests.cs @@ -47,9 +47,38 @@ 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? discountLines = null, - bool isAnnual = true) => + bool isAnnual = true, + int proactiveDiscountMonths = 0) => new() { RenewalDate = "June 12, 2026", @@ -57,6 +86,7 @@ private static BusinessPlanRenewal2020MigrationMailView BuildView( PerUserMonthlyPrice = "$6.00", IsAnnual = isAnnual, TotalPrice = "$23,040.00", - DiscountLines = discountLines ?? [] + DiscountLines = discountLines ?? [], + ProactiveDiscountMonths = proactiveDiscountMonths }; } From 227014f7bf4c310a0fa9a6948768e56f7738983d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:52 -0400 Subject: [PATCH 3/7] refactor(billing): enhance Discount record with coupon ID and duration --- src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f7b64575c47f..94e2469fb18a 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -540,7 +540,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> ResolveDiscountsAsync( OrganizationPlanMigrationCohort cohort, From c397038b849a424e5364ff1ddc1cc4afdef4d976 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:52 -0400 Subject: [PATCH 4/7] feat(billing): populate discount record with coupon duration from Stripe --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 94e2469fb18a..07e9d5199023 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -553,15 +553,19 @@ private async Task> 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( From dd1e66a2bdfa8623ae9f3458244efca5e21bb8a1 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:52 -0400 Subject: [PATCH 5/7] feat(billing): determine and pass proactive discount months to mail view --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 07e9d5199023..0973dc8f27ef 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -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 } }); } From 02664f67f523fbfaeaf25cadcee839e34b844e77 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:52 -0400 Subject: [PATCH 6/7] test(billing): verify proactive discount month calculation in invoice handler --- .../Services/UpcomingInvoiceHandlerTests.cs | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 7bea943ecb7d..b6add1c6cbbb 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -4118,6 +4118,7 @@ public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoCoupon_SendsPriceOn await _mailer.Received(1).SendEmail(Arg.Is(mail => mail.ToEmails.Contains("org@example.com") && !mail.View.HasDiscount && + !mail.View.ShowProactiveDiscountCopy && mail.View.IsAnnual && mail.View.Seats == 320 && mail.View.PerUserMonthlyPrice == "$6" && @@ -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(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()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_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()) + .Returns(true); + _stripeAdapter.GetCouponAsync("loyalty", Arg.Any()) + .Returns(new Coupon { PercentOff = 20, Duration = duration, DurationInMonths = durationInMonths }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _mailer.Received(1).SendEmail(Arg.Is(mail => + mail.View.ProactiveDiscountMonths == expectedMonths && + mail.View.ShowProactiveDiscountCopy == expectedShow)); } [Fact] From b2f31f988d186dc08cc3e47c243dfd353d4a35d5 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 2 Jul 2026 17:23:53 -0400 Subject: [PATCH 7/7] feat(billing): add conditional proactive discount copy to renewal email templates --- .../Renewals/business-plan-renewal-2020-migration.mjml | 4 +++- .../BusinessPlanRenewal2020MigrationMailView.html.hbs | 2 +- .../BusinessPlanRenewal2020MigrationMailView.text.hbs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/business-plan-renewal-2020-migration.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/business-plan-renewal-2020-migration.mjml index b11d262d7279..ff0155241acd 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/business-plan-renewal-2020-migration.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/business-plan-renewal-2020-migration.mjml @@ -31,7 +31,9 @@ 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}} {{#if HasDiscount}} diff --git a/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.html.hbs index c90f645a9ed6..ad39616513b7 100644 --- a/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.html.hbs @@ -30,4 +30,4 @@ .mj-bw-learn-more-footer-responsive-img { display: none !important; } - }
Dear customer,
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 }}:
{{#if HasDiscount}}
{{ Seats }} users x {{ PerUserMonthlyPrice }} / month
{{#each DiscountLines}}
{{ this }} discount
{{/each}}
{{ TotalPrice }}  total / {{ TotalPeriod }}
{{else}}
{{ PerUserMonthlyPrice }}  / month / user
 
{{ Seats }}  users
 
{{ TotalPrice }}  total / {{ TotalPeriod }}
{{/if}}
Bitwarden remains committed to delivering the best value in credential security at a price below every major alternative.
Thank you for your continued trust in Bitwarden.
The Bitwarden Team
Questions? Reply to this email, and the Customer Support team will follow up.

Access Intelligence

Uncover shadow IT, fix at-risk credentials, and prevent costly breaches.

© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file + }
Dear customer,
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 }}.{{#if ShowProactiveDiscountCopy}} As a long-time Bitwarden customer, a loyalty discount will apply to your invoices for the {{ ProactiveDiscountDurationPhrase }}.{{/if}}
{{#if HasDiscount}}
{{ Seats }} users x {{ PerUserMonthlyPrice }} / month
{{#each DiscountLines}}
{{ this }} discount
{{/each}}
{{ TotalPrice }}  total / {{ TotalPeriod }}
{{else}}
{{ PerUserMonthlyPrice }}  / month / user
 
{{ Seats }}  users
 
{{ TotalPrice }}  total / {{ TotalPeriod }}
{{/if}}
Bitwarden remains committed to delivering the best value in credential security at a price below every major alternative.
Thank you for your continued trust in Bitwarden.
The Bitwarden Team
Questions? Reply to this email, and the Customer Support team will follow up.

Access Intelligence

Uncover shadow IT, fix at-risk credentials, and prevent costly breaches.

© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file diff --git a/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.text.hbs index 4f5940090b8a..8d3cef56a1e7 100644 --- a/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/BusinessPlanRenewal2020Migration/BusinessPlanRenewal2020MigrationMailView.text.hbs @@ -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}}