diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 6db0cb6373..0bb51ba9f2 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.Extensions; 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 Bit.Core.Services; 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 @@ public class UpcomingInvoiceHandler( 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 @@ public class UpcomingInvoiceHandler( } } - await (milestone3 - ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) - : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); + await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice); } private async Task AlignOrganizationTaxConcernsAsync( @@ -188,7 +197,16 @@ public class UpcomingInvoiceHandler( } } - 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 @@ public class UpcomingInvoiceHandler( // 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 @@ public class UpcomingInvoiceHandler( { 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 @@ public class UpcomingInvoiceHandler( new SubscriptionItemOptions { Id = passwordManagerItem.Id, - Price = families.PasswordManager.StripePlanId + Price = familiesPlan.PasswordManager.StripePlanId } ], ProrationBehavior = ProrationBehavior.None @@ -266,6 +284,8 @@ public class UpcomingInvoiceHandler( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan); + return true; } catch (Exception exception) { @@ -275,6 +295,7 @@ public class UpcomingInvoiceHandler( organization.Id, @event.Type, @event.Id); + return false; } } @@ -303,14 +324,21 @@ public class UpcomingInvoiceHandler( 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 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignPremiumUsersSubscriptionConcernsAsync( + private async Task AlignPremiumUsersSubscriptionConcernsAsync( User user, Event @event, Subscription subscription) @@ -352,7 +380,7 @@ public class UpcomingInvoiceHandler( { 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 @@ public class UpcomingInvoiceHandler( ], ProrationBehavior = ProrationBehavior.None }); + await SendPremiumRenewalEmailAsync(user, plan); + return true; } catch (Exception exception) { @@ -379,6 +409,7 @@ public class UpcomingInvoiceHandler( "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 @@ public class UpcomingInvoiceHandler( } } - 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(updatedUpcomingEmail); + + 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(email); } #endregion diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index 07e9fdf3d1..a71e3b5ee9 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 0000000000..e7364e34b0 --- /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 new file mode 100644 index 0000000000..dcf193875a --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + 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 c50a5d1292..0000000000 --- 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 0000000000..eb7bef4322 --- /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 0000000000..ac6b80993c --- /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 0000000000..002a48cf10 --- /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 aeca436dbb..0000000000 --- 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 a044171fe5..0000000000 --- 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 a2db92bac2..0000000000 --- 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 89c926ee31..f1d8c4ba2e 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.Payment.Queries; 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 @@ public class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 @@ public class UpcomingInvoiceHandlerTests } [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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 @@ public class UpcomingInvoiceHandlerTests Arg.Any>(), Arg.Any()); - await _mailer.DidNotReceive().SendEmail(Arg.Any()); + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -900,7 +897,7 @@ public class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests 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 class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -977,8 +974,8 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, @@ -989,7 +986,7 @@ public class UpcomingInvoiceHandlerTests Id = premiumAccessItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -998,7 +995,7 @@ public class UpcomingInvoiceHandlerTests 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 +1042,10 @@ public class UpcomingInvoiceHandlerTests 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 +1064,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1079,14 +1077,14 @@ public class UpcomingInvoiceHandlerTests 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 +1093,7 @@ public class UpcomingInvoiceHandlerTests 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 +1154,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1168,14 +1166,14 @@ public class UpcomingInvoiceHandlerTests 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 +1182,7 @@ public class UpcomingInvoiceHandlerTests 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 +1230,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1244,14 +1242,10 @@ public class UpcomingInvoiceHandlerTests 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 +1254,7 @@ public class UpcomingInvoiceHandlerTests 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 +1301,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1319,14 +1313,10 @@ public class UpcomingInvoiceHandlerTests 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 +1325,7 @@ public class UpcomingInvoiceHandlerTests 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 +1368,7 @@ public class UpcomingInvoiceHandlerTests } [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 +1383,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1406,14 +1396,14 @@ public class UpcomingInvoiceHandlerTests 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 +1412,7 @@ public class UpcomingInvoiceHandlerTests 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 +1453,16 @@ public class UpcomingInvoiceHandlerTests 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 +1482,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1500,20 +1495,21 @@ public class UpcomingInvoiceHandlerTests 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 +1518,7 @@ public class UpcomingInvoiceHandlerTests 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 +1565,10 @@ public class UpcomingInvoiceHandlerTests 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 +1588,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1604,20 +1601,21 @@ public class UpcomingInvoiceHandlerTests 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 +1624,7 @@ public class UpcomingInvoiceHandlerTests 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 +1671,10 @@ public class UpcomingInvoiceHandlerTests 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 +1695,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1709,25 +1708,27 @@ public class UpcomingInvoiceHandlerTests 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 +1737,7 @@ public class UpcomingInvoiceHandlerTests 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 +1786,10 @@ public class UpcomingInvoiceHandlerTests 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 +1808,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1819,14 +1821,14 @@ public class UpcomingInvoiceHandlerTests 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 +1837,7 @@ public class UpcomingInvoiceHandlerTests 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 +1897,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1907,14 +1909,14 @@ public class UpcomingInvoiceHandlerTests 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 +1925,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } };