[PM-26461] Send F2020 renewal email (#6638)

* Send F2020 renewal email

* Implement and use simple hero

* Cy's feedback
This commit is contained in:
Alex Morask 2025-11-26 08:37:48 -06:00 committed by GitHub
parent 9fcedd5b91
commit 219993cc2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 921 additions and 223 deletions

View File

@ -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(
/// <summary>
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
/// </summary>
/// <param name="organization">The organization whose subscription is being updated.</param>
/// <param name="event">The Stripe event associated with this operation.</param>
/// <param name="subscription">The organization's subscription.</param>
/// <param name="plan">The organization's current plan.</param>
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
/// <returns>Whether the operation resulted in an updated subscription.</returns>
private async Task<bool> 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<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
}
}
@ -341,7 +369,7 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
private async Task<bool> 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<string> 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

View File

@ -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"

View File

@ -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(
`
<mj-section
full-width="full-width"
background-color="#175ddc"
border-radius="4px 4px 0px 0px"
padding="20px 20px"
>
<mj-column width="100%">
<mj-image
align="left"
src="https://bitwarden.com/images/logo-horizontal-white.png"
width="150px"
height="30px"
padding="10px 5px"
></mj-image>
</mj-column>
</mj-section>
`,
);
}
}
module.exports = MjBwSimpleHero;

View File

@ -0,0 +1,36 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
</mj-head>
<!-- Blue Header Section-->
<mj-body css-class="border-fix">
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
<mj-bw-simple-hero />
</mj-wrapper>
<!-- Main Content Section -->
<mj-wrapper padding="0px 20px 0px 20px">
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
Questions? Contact <a href="mailto:support@bitwarden.com" class="link">support@bitwarden.com</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>
</mj-wrapper>
<!-- Learn More Section -->
<mj-wrapper padding="0px 20px 10px 20px">
<mj-bw-learn-more-footer />
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>

View File

@ -1,27 +0,0 @@
<mjml>
<mj-head>
<mj-include path="../components/head.mjml" />
</mj-head>
<mj-body background-color="#f6f6f6">
<mj-include path="../components/logo.mjml" />
<mj-wrapper
background-color="#fff"
border="1px solid #e9e9e9"
css-class="border-fix"
padding="0"
>
<mj-section>
<mj-column>
<mj-text>
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.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-include path="../components/footer.mjml" />
</mj-body>
</mjml>

View File

@ -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<Families2020RenewalMailView>
{
public override string Subject { get => "Your Bitwarden Families renewal is updating"; }
}

View File

@ -0,0 +1,619 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Your Bitwarden Families renewal is updating
</h1></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/account-fill.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Questions? Contact <a href="mailto:support@bitwarden.com" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">support@bitwarden.com</a></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -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

View File

@ -1,10 +0,0 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
public class UpdatedInvoiceUpcomingView : BaseMailView;
public class UpdatedInvoiceUpcomingMail : BaseMail<UpdatedInvoiceUpcomingView>
{
public override string Subject { get => "Your Subscription Will Renew Soon"; }
}

File diff suppressed because one or more lines are too long

View File

@ -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}}

View File

@ -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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { 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<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@ -708,11 +700,16 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Verify that email was still sent despite the exception
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(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<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
// Verify renewal email was NOT sent
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
@ -727,7 +724,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@ -737,12 +734,12 @@ public class UpcomingInvoiceHandlerTests
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@ -784,7 +781,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@ -841,7 +838,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@ -885,7 +882,7 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<List<string>>(),
Arg.Any<bool>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<UpdatedInvoiceUpcomingMail>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
@ -900,7 +897,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@ -964,7 +961,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -977,8 +974,8 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<string, string>()
@ -998,7 +995,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1079,14 +1077,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@ -1095,7 +1093,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@ -1156,7 +1154,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1168,14 +1166,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@ -1184,7 +1182,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@ -1232,7 +1230,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1244,14 +1242,10 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<string, string>()
@ -1260,7 +1254,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@ -1307,7 +1301,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1319,14 +1313,10 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<string, string>()
@ -1335,7 +1325,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1406,14 +1396,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@ -1422,7 +1412,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@ -1463,11 +1453,16 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should still attempt to send email despite the failure
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(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<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
// Verify renewal email was NOT sent
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
@ -1487,7 +1482,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1500,20 +1495,21 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<string, string>()
@ -1522,7 +1518,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1604,20 +1601,21 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<string, string>()
@ -1626,7 +1624,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1709,25 +1708,27 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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<string, string>()
@ -1736,7 +1737,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { 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<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(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<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1819,14 +1821,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@ -1835,7 +1837,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@ -1895,7 +1897,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@ -1907,14 +1909,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@ -1923,7 +1925,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};