diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f7b64575c47f..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 } }); } @@ -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> ResolveDiscountsAsync( OrganizationPlanMigrationCohort cohort, @@ -553,15 +555,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( 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.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 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}} 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] 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 }; }