From 244b87135af77aa98c87485635c494542482fb1d Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 24 Nov 2025 14:40:39 -0600 Subject: [PATCH 1/5] Send F2020 renewal email --- .../Implementations/UpcomingInvoiceHandler.cs | 108 ++- .../Renewals/families-2020-renewal.mjml | 39 ++ .../Mjml/emails/invoice-upcoming.mjml | 27 - .../Families2020RenewalMailView.cs | 13 + .../Families2020RenewalMailView.html.hbs | 619 ++++++++++++++++++ .../Families2020RenewalMailView.text.hbs | 3 + .../UpdatedInvoiceUpcomingView.cs | 10 - .../UpdatedInvoiceUpcomingView.html.hbs | 30 - .../UpdatedInvoiceUpcomingView.text.hbs | 3 - .../Services/UpcomingInvoiceHandlerTests.cs | 255 ++++---- 10 files changed, 884 insertions(+), 223 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml delete mode 100644 src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs delete mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs delete mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs delete mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 6db0cb63732e..0bb51ba9f23f 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core; +using System.Globalization; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; @@ -8,7 +9,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; -using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -16,6 +17,7 @@ using Stripe; using Event = Stripe.Event; using Plan = Bit.Core.Models.StaticStore.Plan; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; namespace Bit.Billing.Services.Implementations; @@ -107,13 +109,22 @@ private async Task HandleOrganizationUpcomingInvoiceAsync( var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); - await AlignOrganizationSubscriptionConcernsAsync( + var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync( organization, @event, subscription, plan, milestone3); + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } + // Don't send the upcoming invoice email unless the organization's on an annual plan. if (!plan.IsAnnual) { @@ -135,9 +146,7 @@ await AlignOrganizationSubscriptionConcernsAsync( } } - await (milestone3 - ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) - : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); + await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice); } private async Task AlignOrganizationTaxConcernsAsync( @@ -188,7 +197,16 @@ await stripeFacade.UpdateSubscription(subscription.Id, } } - private async Task AlignOrganizationSubscriptionConcernsAsync( + /// + /// Aligns the organization's subscription details with the specified plan and milestone requirements. + /// + /// The organization whose subscription is being updated. + /// The Stripe event associated with this operation. + /// The organization's subscription. + /// The organization's current plan. + /// A flag indicating whether the third milestone is enabled. + /// Whether the operation resulted in an updated subscription. + private async Task AlignOrganizationSubscriptionConcernsAsync( Organization organization, Event @event, Subscription subscription, @@ -198,7 +216,7 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( // currently these are the only plans that need aligned and both require the same flag and share most of the logic if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025)) { - return; + return false; } var passwordManagerItem = @@ -208,15 +226,15 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( { logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", organization.Id, @event.Type, @event.Id); - return; + return false; } - var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); - organization.PlanType = families.Type; - organization.Plan = families.Name; - organization.UsersGetPremium = families.UsersGetPremium; - organization.Seats = families.PasswordManager.BaseSeats; + organization.PlanType = familiesPlan.Type; + organization.Plan = familiesPlan.Name; + organization.UsersGetPremium = familiesPlan.UsersGetPremium; + organization.Seats = familiesPlan.PasswordManager.BaseSeats; var options = new SubscriptionUpdateOptions { @@ -225,7 +243,7 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( new SubscriptionItemOptions { Id = passwordManagerItem.Id, - Price = families.PasswordManager.StripePlanId + Price = familiesPlan.PasswordManager.StripePlanId } ], ProrationBehavior = ProrationBehavior.None @@ -266,6 +284,8 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan); + return true; } catch (Exception exception) { @@ -275,6 +295,7 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( organization.Id, @event.Type, @event.Id); + return false; } } @@ -303,14 +324,21 @@ private async Task HandlePremiumUsersUpcomingInvoiceAsync( var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); if (milestone2Feature) { - await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } } if (user.Premium) { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); } } @@ -341,7 +369,7 @@ await stripeFacade.UpdateSubscription(subscription.Id, } } - private async Task AlignPremiumUsersSubscriptionConcernsAsync( + private async Task AlignPremiumUsersSubscriptionConcernsAsync( User user, Event @event, Subscription subscription) @@ -352,7 +380,7 @@ private async Task AlignPremiumUsersSubscriptionConcernsAsync( { logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", user.Id, @event.Type, @event.Id); - return; + return false; } try @@ -371,6 +399,8 @@ await stripeFacade.UpdateSubscription(subscription.Id, ], ProrationBehavior = ProrationBehavior.None }); + await SendPremiumRenewalEmailAsync(user, plan); + return true; } catch (Exception exception) { @@ -379,6 +409,7 @@ await stripeFacade.UpdateSubscription(subscription.Id, "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", user.Id, @event.Id); + return false; } } @@ -513,15 +544,38 @@ await mailService.SendInvoiceUpcoming( } } - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + private async Task SendFamiliesRenewalEmailAsync( + Organization organization, + Plan familiesPlan) { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + var email = new Families2020RenewalMail { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() + ToEmails = [organization.BillingEmail], + View = new Families2020RenewalMailView + { + MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); + } + + private async Task SendPremiumRenewalEmailAsync( + User user, + PremiumPlan premiumPlan) + { + /* TODO: Replace with proper premium renewal email template once finalized. + Using Families2020RenewalMail as a temporary stop-gap. */ + var email = new Families2020RenewalMail + { + ToEmails = [user.Email], + View = new Families2020RenewalMailView + { + MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) + } }; - await mailer.SendEmail(updatedUpcomingEmail); + + await mailer.SendEmail(email); } #endregion diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml new file mode 100644 index 000000000000..9dd4a329faa2 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually. + + + Questions? Contact support@bitwarden.com + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml deleted file mode 100644 index c50a5d129270..000000000000 --- a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. - - - - - - - - - diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs new file mode 100644 index 000000000000..eb7bef432267 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs @@ -0,0 +1,13 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; + +public class Families2020RenewalMailView : BaseMailView +{ + public required string MonthlyRenewalPrice { get; set; } +} + +public class Families2020RenewalMail : BaseMail +{ + public override string Subject { get => "Your Bitwarden Families renewal is updating"; } +} diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs new file mode 100644 index 000000000000..ac6b80993cb7 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs @@ -0,0 +1,619 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Your Bitwarden Families renewal is updating +

+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.
+ +
+ +
Questions? Contact support@bitwarden.com
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 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 +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs new file mode 100644 index 000000000000..002a48cf1015 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs @@ -0,0 +1,3 @@ +Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually. + +Questions? Contact support@bitwarden.com diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs deleted file mode 100644 index aeca436dbb65..000000000000 --- a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Bit.Core.Platform.Mail.Mailer; - -namespace Bit.Core.Models.Mail.UpdatedInvoiceIncoming; - -public class UpdatedInvoiceUpcomingView : BaseMailView; - -public class UpdatedInvoiceUpcomingMail : BaseMail -{ - public override string Subject { get => "Your Subscription Will Renew Soon"; } -} diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs deleted file mode 100644 index a044171fe50d..000000000000 --- a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs +++ /dev/null @@ -1,30 +0,0 @@ -
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.

© 2025 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/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs deleted file mode 100644 index a2db92bac25f..000000000000 --- a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs +++ /dev/null @@ -1,3 +0,0 @@ -{{#>BasicTextLayout}} - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. -{{/BasicTextLayout}} diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 89c926ee310b..53e4e4dde9f0 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -1,4 +1,5 @@ -using Bit.Billing.Services; +using System.Globalization; +using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -10,7 +11,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; -using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -117,7 +118,7 @@ public async Task HandleAsync_WhenValidUser_SendsEmail() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -126,10 +127,7 @@ public async Task HandleAsync_WhenValidUser_SendsEmail() CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } } - } + Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, Customer = new Customer { Id = customerId }, @@ -199,7 +197,7 @@ public async Task NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -208,10 +206,7 @@ public async Task CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } - } + Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, Customer = new Customer @@ -233,7 +228,7 @@ public async Task var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -272,11 +267,12 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && o.ProrationBehavior == "none")); - // Verify the updated invoice email was sent + // Verify the updated invoice email was sent with correct price await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -291,7 +287,7 @@ public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -307,7 +303,7 @@ public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; var organization = new Organization @@ -375,7 +371,7 @@ public async Task NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -395,7 +391,7 @@ public async Task var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; var organization = new Organization @@ -469,7 +465,7 @@ public async Task HandleAsync_WhenValidOrganization_SendsEmail() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -489,7 +485,7 @@ public async Task HandleAsync_WhenValidOrganization_SendsEmail() var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; var organization = new Organization @@ -560,7 +556,7 @@ public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -576,7 +572,7 @@ public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "UK" }, TaxExempt = TaxExempt.None }; @@ -622,9 +618,8 @@ await _mailService.Received(1).SendProviderInvoiceUpcoming( } [Fact] - public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail() + public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsTraditionalEmail() { - // Arrange // Arrange var parsedEvent = new Event { Id = "evt_123" }; var customerId = "cus_123"; @@ -637,7 +632,7 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -646,10 +641,7 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } - } + Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Customer = new Customer @@ -671,7 +663,7 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -708,11 +700,16 @@ public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAn Arg.Any(), Arg.Any>()); - // Verify that email was still sent despite the exception - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + // Verify that traditional email was sent when update fails + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + + // Verify renewal email was NOT sent + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -727,7 +724,7 @@ public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -737,12 +734,12 @@ public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() Items = new StripeList(), AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, Customer = new Customer { Id = "cus_123" }, - Metadata = new Dictionary(), + Metadata = new Dictionary() }; var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -784,7 +781,7 @@ public async Task HandleAsync_WhenZeroAmountInvoice_DoesNothing() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Free Item" } } + Data = [new() { Description = "Free Item" }] } }; var subscription = new Subscription @@ -800,7 +797,7 @@ public async Task HandleAsync_WhenZeroAmountInvoice_DoesNothing() var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -841,7 +838,7 @@ public async Task HandleAsync_WhenUserNotFound_DoesNothing() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -856,7 +853,7 @@ public async Task HandleAsync_WhenUserNotFound_DoesNothing() var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -885,7 +882,7 @@ await _mailService.DidNotReceive().SendInvoiceUpcoming( Arg.Any>(), Arg.Any()); - await _mailer.DidNotReceive().SendEmail(Arg.Any()); + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -900,7 +897,7 @@ public async Task HandleAsync_WhenProviderNotFound_DoesNothing() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -915,7 +912,7 @@ public async Task HandleAsync_WhenProviderNotFound_DoesNothing() var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -964,7 +961,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -977,19 +974,20 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = premiumAccessItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -998,7 +996,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1045,9 +1043,10 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1066,7 +1065,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutP NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1079,14 +1078,14 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutP CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1095,7 +1094,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutP var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1156,7 +1155,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNot NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1168,14 +1167,14 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNot CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1184,7 +1183,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNot var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1232,7 +1231,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesN NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1244,14 +1243,10 @@ public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesN CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() - { - Id = "si_pm_123", - Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } - } - } + Data = + [ + new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1260,7 +1255,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesN var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1307,7 +1302,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFou NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1319,14 +1314,10 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFou CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() - { - Id = "si_different_item", - Price = new Price { Id = "different-price-id" } - } - } + Data = + [ + new() { Id = "si_different_item", Price = new Price { Id = "different-price-id" } } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1335,7 +1326,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFou var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1378,7 +1369,7 @@ await _stripeFacade.DidNotReceive().UpdateSubscription( } [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() + public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndSendsTraditionalEmail() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -1393,7 +1384,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1406,14 +1397,14 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1422,7 +1413,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1463,11 +1454,16 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() Arg.Any(), Arg.Any>()); - // Should still attempt to send email despite the failure - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + // Should send traditional email when update fails + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + + // Verify renewal email was NOT sent + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -1487,7 +1483,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesIt NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1500,20 +1496,21 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesIt CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = seatAddOnItemId, Price = new Price { Id = "personal-org-seat-annually" }, Quantity = 3 } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1522,7 +1519,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesIt var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1569,9 +1566,10 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1591,7 +1589,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_ NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1604,20 +1602,21 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_ CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = seatAddOnItemId, Price = new Price { Id = "personal-org-seat-annually" }, Quantity = 1 } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1626,7 +1625,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_ var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1673,9 +1672,10 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1696,7 +1696,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddO NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1709,25 +1709,27 @@ public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddO CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = premiumAccessItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } }, + new() { Id = seatAddOnItemId, Price = new Price { Id = "personal-org-seat-annually" }, Quantity = 2 } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1736,7 +1738,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddO var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1785,9 +1787,10 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1806,7 +1809,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesS NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1819,14 +1822,14 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesS CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1835,7 +1838,7 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesS var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1895,7 +1898,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNot NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1907,14 +1910,14 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNot CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1923,7 +1926,7 @@ public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNot var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; From f999907245abb478856520fb5900f9ba872946ed Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Tue, 25 Nov 2025 11:07:45 -0600 Subject: [PATCH 2/5] Implement and use simple hero --- src/Core/MailTemplates/Mjml/.mjmlconfig | 1 + .../Mjml/components/mj-bw-simple-hero.js | 40 +++++++++++++++++++ .../Renewals/families-2020-renewal.mjml | 5 +-- 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index 07e9fdf3d16e..a71e3b5ee942 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -1,6 +1,7 @@ { "packages": [ "components/mj-bw-hero", + "components/mj-bw-simple-hero", "components/mj-bw-icon-row", "components/mj-bw-learn-more-footer", "emails/AdminConsole/components/mj-bw-inviter-info" diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js b/src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js new file mode 100644 index 000000000000..e7364e34b0ae --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js @@ -0,0 +1,40 @@ +const { BodyComponent } = require("mjml-core"); + +class MjBwSimpleHero extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-simple-hero"], + "mj-wrapper": ["mj-bw-simple-hero"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-simple-hero": [], + }; + + static allowedAttributes = {}; + + static defaultAttributes = {}; + + render() { + return this.renderMJML( + ` + + + + + + `, + ); + } +} + +module.exports = MjBwSimpleHero; diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml index 9dd4a329faa2..dcf193875a07 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml @@ -6,10 +6,7 @@ - + From a386350419ccff494eaf378d847cd579145ccaac Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Tue, 25 Nov 2025 11:09:56 -0600 Subject: [PATCH 3/5] Cy's feedback --- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 53e4e4dde9f0..f1d8c4ba2eab 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -981,7 +981,6 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, - new() { Id = premiumAccessItemId, From 75ef19d5b04e2f6c8b86107d38d56cb7b4cb3653 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:33:26 -0600 Subject: [PATCH 4/5] [PM-28100] families 2019 email --- src/Billing/Services/IStripeFacade.cs | 6 + .../Services/Implementations/StripeFacade.cs | 8 + .../Implementations/UpcomingInvoiceHandler.cs | 48 +- .../Renewals/families-2019-renewal.mjml | 42 ++ .../Families2019RenewalMailView.cs | 16 + .../Families2019RenewalMailView.html.hbs | 584 ++++++++++++++++++ .../Families2019RenewalMailView.text.hbs | 7 + .../Services/UpcomingInvoiceHandlerTests.cs | 55 +- 8 files changed, 756 insertions(+), 10 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 90db4a4c825e..f821eeed5f2e 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -116,4 +116,10 @@ Task GetTestClock( TestClockGetOptions testClockGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 7b714b4a8e52..bb72091bc69d 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade private readonly DiscountService _discountService = new(); private readonly SetupIntentService _setupIntentService = new(); private readonly TestClockService _testClockService = new(); + private readonly CouponService _couponService = new(); public async Task GetCharge( string chargeId, @@ -143,4 +144,11 @@ public Task GetTestClock( RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => _testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken); + + public Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken); } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 0bb51ba9f23f..37cf58ab2a27 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -9,6 +9,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; +using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; @@ -284,7 +285,7 @@ private async Task AlignOrganizationSubscriptionConcernsAsync( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); - await SendFamiliesRenewalEmailAsync(organization, familiesPlan); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan); return true; } catch (Exception exception) @@ -546,7 +547,18 @@ await mailService.SendInvoiceUpcoming( private async Task SendFamiliesRenewalEmailAsync( Organization organization, - Plan familiesPlan) + Plan familiesPlan, + Plan planBeforeAlignment) + { + await (planBeforeAlignment switch + { + { Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan), + { Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan), + _ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().") + }); + } + + private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan) { var email = new Families2020RenewalMail { @@ -560,6 +572,38 @@ private async Task SendFamiliesRenewalEmailAsync( await mailer.SendEmail(email); } + private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan) + { + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + if (coupon == null) + { + logger.LogWarning("Could not find coupon for sending families 2019 email with ID: {CouponID}", CouponIDs.Milestone3SubscriptionDiscount); + return; + } + + if (coupon.PercentOff == null) + { + logger.LogWarning("The coupon for sending families 2019 email with ID: {CouponID} has a null PercentOff.", CouponIDs.Milestone3SubscriptionDiscount); + return; + } + + var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100; + + var email = new Families2019RenewalMail + { + ToEmails = [organization.BillingEmail], + View = new Families2019RenewalMailView + { + BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")), + BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); + } + private async Task SendPremiumRenewalEmailAsync( User user, PremiumPlan premiumPlan) diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml new file mode 100644 index 000000000000..092ae303de09 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually + at {{BaseAnnualRenewalPrice}} + tax. + + + As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. + + + Questions? Contact + support@bitwarden.com + + + + + + + + + + + + + + + + diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs new file mode 100644 index 000000000000..e3aff02f5ddd --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs @@ -0,0 +1,16 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; + +public class Families2019RenewalMailView : BaseMailView +{ + public required string BaseMonthlyRenewalPrice { get; set; } + public required string BaseAnnualRenewalPrice { get; set; } + public required string DiscountedAnnualRenewalPrice { get; set; } + public required string DiscountAmount { get; set; } +} + +public class Families2019RenewalMail : BaseMail +{ + public override string Subject { get => "Your Bitwarden Families renewal is updating"; } +} diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs new file mode 100644 index 000000000000..227613999b23 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs @@ -0,0 +1,584 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ +
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually + at {{BaseAnnualRenewalPrice}} + tax.
+ +
+ +
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
+ +
+ +
Questions? Contact + support@bitwarden.com
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 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/Families2019Renewal/Families2019RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs new file mode 100644 index 000000000000..88d64f9acf77 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs @@ -0,0 +1,7 @@ +Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually +at {{BaseAnnualRenewalPrice}} + tax. + +As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. +This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. + +Questions? Contact support@bitwarden.com diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index f1d8c4ba2eab..483a850bd83d 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; +using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; @@ -1006,8 +1007,11 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesS PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1033,6 +1037,8 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1042,10 +1048,13 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1529,8 +1538,11 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesIt PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1556,6 +1568,8 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1565,10 +1579,13 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1635,8 +1652,11 @@ public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_ PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1662,6 +1682,8 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1671,10 +1693,13 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1748,8 +1773,11 @@ public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddO PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1777,6 +1805,8 @@ await _stripeFacade.Received(1).UpdateSubscription( o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1786,10 +1816,13 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1879,6 +1912,12 @@ await _organizationRepository.Received(1).ReplaceAsync( org.Plan == familiesPlan.Name && org.UsersGetPremium == familiesPlan.UsersGetPremium && org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] From b6bded9493a79f77a8667c13dc5ce95950f0ce2e Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:54:58 -0600 Subject: [PATCH 5/5] pr feedback --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 37cf58ab2a27..2686ff941202 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -577,14 +577,12 @@ private async Task SendFamilies2019RenewalEmailAsync(Organization organization, var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); if (coupon == null) { - logger.LogWarning("Could not find coupon for sending families 2019 email with ID: {CouponID}", CouponIDs.Milestone3SubscriptionDiscount); - return; + throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found"); } if (coupon.PercentOff == null) { - logger.LogWarning("The coupon for sending families 2019 email with ID: {CouponID} has a null PercentOff.", CouponIDs.Milestone3SubscriptionDiscount); - return; + throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null"); } var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100;