mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[PM-23717] premium renewal email (#6672)
* [PM-23717] premium renewal email * pr feedback * pr feedback
This commit is contained in:
@@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing;
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Platform.Mail.Mailer;
|
using Bit.Core.Platform.Mail.Mailer;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@@ -606,14 +607,27 @@ public class UpcomingInvoiceHandler(
|
|||||||
User user,
|
User user,
|
||||||
PremiumPlan premiumPlan)
|
PremiumPlan premiumPlan)
|
||||||
{
|
{
|
||||||
/* TODO: Replace with proper premium renewal email template once finalized.
|
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
|
||||||
Using Families2020RenewalMail as a temporary stop-gap. */
|
if (coupon == null)
|
||||||
var email = new Families2020RenewalMail
|
{
|
||||||
|
throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.PercentOff == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||||
|
|
||||||
|
var email = new PremiumRenewalMail
|
||||||
{
|
{
|
||||||
ToEmails = [user.Email],
|
ToEmails = [user.Email],
|
||||||
View = new Families2020RenewalMailView
|
View = new PremiumRenewalMailView
|
||||||
{
|
{
|
||||||
MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))
|
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
|
||||||
|
DiscountAmount = $"{coupon.PercentOff}%",
|
||||||
|
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<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 Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
|
||||||
|
</mj-text>
|
||||||
|
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
|
||||||
|
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||||
|
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/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>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Platform.Mail.Mailer;
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||||
|
|
||||||
|
public class PremiumRenewalMailView : BaseMailView
|
||||||
|
{
|
||||||
|
public required string BaseMonthlyRenewalPrice { get; set; }
|
||||||
|
public required string DiscountedMonthlyRenewalPrice { get; set; }
|
||||||
|
public required string DiscountAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PremiumRenewalMail : BaseMail<PremiumRenewalMailView>
|
||||||
|
{
|
||||||
|
public override string Subject { get => "Your Bitwarden Premium renewal is updating"; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
<!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-100 { width:100% !important; max-width: 100%; }
|
||||||
|
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||||
|
.mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||||
|
.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%; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
|
||||||
|
@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 20px;text-align:center;">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:580px;" ><![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 5px;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>
|
||||||
|
|
||||||
|
</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 Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<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;">As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||||
|
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/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>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
|
||||||
|
|
||||||
|
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
|
||||||
|
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
|
||||||
|
|
||||||
|
Questions? Contact support@bitwarden.com
|
||||||
@@ -13,6 +13,7 @@ using Bit.Core.Billing.Pricing.Premium;
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Platform.Mail.Mailer;
|
using Bit.Core.Platform.Mail.Mailer;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@@ -253,6 +254,9 @@ public class UpcomingInvoiceHandlerTests
|
|||||||
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
|
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
|
||||||
.Returns(true);
|
.Returns(true);
|
||||||
|
|
||||||
|
var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount };
|
||||||
|
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sut.HandleAsync(parsedEvent);
|
await _sut.HandleAsync(parsedEvent);
|
||||||
@@ -260,6 +264,7 @@ public class UpcomingInvoiceHandlerTests
|
|||||||
// Assert
|
// Assert
|
||||||
await _userRepository.Received(1).GetByIdAsync(_userId);
|
await _userRepository.Received(1).GetByIdAsync(_userId);
|
||||||
await _pricingClient.Received(1).GetAvailablePremiumPlan();
|
await _pricingClient.Received(1).GetAvailablePremiumPlan();
|
||||||
|
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
|
||||||
await _stripeFacade.Received(1).UpdateSubscription(
|
await _stripeFacade.Received(1).UpdateSubscription(
|
||||||
Arg.Is("sub_123"),
|
Arg.Is("sub_123"),
|
||||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||||
@@ -269,11 +274,15 @@ public class UpcomingInvoiceHandlerTests
|
|||||||
o.ProrationBehavior == "none"));
|
o.ProrationBehavior == "none"));
|
||||||
|
|
||||||
// Verify the updated invoice email was sent with correct price
|
// Verify the updated invoice email was sent with correct price
|
||||||
|
var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||||
await _mailer.Received(1).SendEmail(
|
await _mailer.Received(1).SendEmail(
|
||||||
Arg.Is<Families2020RenewalMail>(email =>
|
Arg.Is<PremiumRenewalMail>(email =>
|
||||||
email.ToEmails.Contains("user@example.com") &&
|
email.ToEmails.Contains("user@example.com") &&
|
||||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
email.Subject == "Your Bitwarden Premium renewal is updating" &&
|
||||||
email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))));
|
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||||
|
email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||||
|
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1474,6 +1483,200 @@ public class UpcomingInvoiceHandlerTests
|
|||||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
|
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||||
|
var customerId = "cus_123";
|
||||||
|
var subscriptionId = "sub_123";
|
||||||
|
var passwordManagerItemId = "si_pm_123";
|
||||||
|
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
AmountDue = 40000,
|
||||||
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||||
|
Lines = new StripeList<InvoiceLineItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Description = "Test Item" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var families2019Plan = new Families2019Plan();
|
||||||
|
var familiesPlan = new FamiliesPlan();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = subscriptionId,
|
||||||
|
CustomerId = customerId,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = passwordManagerItemId,
|
||||||
|
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = customerId,
|
||||||
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = _organizationId,
|
||||||
|
BillingEmail = "org@example.com",
|
||||||
|
PlanType = PlanType.FamiliesAnnually2019
|
||||||
|
};
|
||||||
|
|
||||||
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||||
|
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||||
|
_stripeEventUtilityService
|
||||||
|
.GetIdsFromMetadata(subscription.Metadata)
|
||||||
|
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||||
|
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||||
|
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||||
|
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||||
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null);
|
||||||
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||||
|
.Returns(subscription);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.HandleAsync(parsedEvent);
|
||||||
|
|
||||||
|
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||||
|
_logger.Received(1).Log(
|
||||||
|
LogLevel.Error,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o =>
|
||||||
|
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
|
||||||
|
o.ToString().Contains(parsedEvent.Type) &&
|
||||||
|
o.ToString().Contains(parsedEvent.Id)),
|
||||||
|
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")),
|
||||||
|
Arg.Any<Func<object, Exception, string>>());
|
||||||
|
|
||||||
|
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||||
|
var customerId = "cus_123";
|
||||||
|
var subscriptionId = "sub_123";
|
||||||
|
var passwordManagerItemId = "si_pm_123";
|
||||||
|
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
AmountDue = 40000,
|
||||||
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||||
|
Lines = new StripeList<InvoiceLineItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Description = "Test Item" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var families2019Plan = new Families2019Plan();
|
||||||
|
var familiesPlan = new FamiliesPlan();
|
||||||
|
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = subscriptionId,
|
||||||
|
CustomerId = customerId,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = passwordManagerItemId,
|
||||||
|
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = customerId,
|
||||||
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var organization = new Organization
|
||||||
|
{
|
||||||
|
Id = _organizationId,
|
||||||
|
BillingEmail = "org@example.com",
|
||||||
|
PlanType = PlanType.FamiliesAnnually2019
|
||||||
|
};
|
||||||
|
|
||||||
|
var coupon = new Coupon
|
||||||
|
{
|
||||||
|
Id = CouponIDs.Milestone3SubscriptionDiscount,
|
||||||
|
PercentOff = null
|
||||||
|
};
|
||||||
|
|
||||||
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||||
|
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||||
|
_stripeEventUtilityService
|
||||||
|
.GetIdsFromMetadata(subscription.Metadata)
|
||||||
|
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||||
|
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||||
|
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||||
|
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||||
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||||
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||||
|
.Returns(subscription);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.HandleAsync(parsedEvent);
|
||||||
|
|
||||||
|
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||||
|
_logger.Received(1).Log(
|
||||||
|
LogLevel.Error,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o =>
|
||||||
|
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
|
||||||
|
o.ToString().Contains(parsedEvent.Type) &&
|
||||||
|
o.ToString().Contains(parsedEvent.Id)),
|
||||||
|
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")),
|
||||||
|
Arg.Any<Func<object, Exception, string>>());
|
||||||
|
|
||||||
|
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
|
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
|
||||||
{
|
{
|
||||||
@@ -1996,4 +2199,332 @@ public class UpcomingInvoiceHandlerTests
|
|||||||
await _organizationRepository.DidNotReceive().ReplaceAsync(
|
await _organizationRepository.DidNotReceive().ReplaceAsync(
|
||||||
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
|
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Premium Renewal Email Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var parsedEvent = new Event { Id = "evt_123" };
|
||||||
|
var customerId = "cus_123";
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
AmountDue = 10000,
|
||||||
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||||
|
Lines = new StripeList<InvoiceLineItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Description = "Test Item" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = "sub_123",
|
||||||
|
CustomerId = customerId,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||||
|
},
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||||
|
Customer = new Customer { Id = customerId },
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
};
|
||||||
|
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
|
||||||
|
var plan = new PremiumPlan
|
||||||
|
{
|
||||||
|
Name = "Premium",
|
||||||
|
Available = true,
|
||||||
|
LegacyYear = null,
|
||||||
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
||||||
|
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
|
||||||
|
};
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = customerId,
|
||||||
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
|
||||||
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||||
|
};
|
||||||
|
|
||||||
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||||
|
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||||
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||||
|
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||||
|
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||||
|
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null);
|
||||||
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||||
|
.Returns(subscription);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.HandleAsync(parsedEvent);
|
||||||
|
|
||||||
|
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||||
|
_logger.Received(1).Log(
|
||||||
|
LogLevel.Error,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o =>
|
||||||
|
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
|
||||||
|
o.ToString().Contains(parsedEvent.Id)),
|
||||||
|
Arg.Is<Exception>(e => e is InvalidOperationException
|
||||||
|
&& e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"),
|
||||||
|
Arg.Any<Func<object, Exception, string>>());
|
||||||
|
|
||||||
|
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var parsedEvent = new Event { Id = "evt_123" };
|
||||||
|
var customerId = "cus_123";
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
AmountDue = 10000,
|
||||||
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||||
|
Lines = new StripeList<InvoiceLineItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Description = "Test Item" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = "sub_123",
|
||||||
|
CustomerId = customerId,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||||
|
},
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||||
|
Customer = new Customer { Id = customerId },
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
};
|
||||||
|
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
|
||||||
|
var plan = new PremiumPlan
|
||||||
|
{
|
||||||
|
Name = "Premium",
|
||||||
|
Available = true,
|
||||||
|
LegacyYear = null,
|
||||||
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
||||||
|
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
|
||||||
|
};
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = customerId,
|
||||||
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
|
||||||
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||||
|
};
|
||||||
|
var coupon = new Coupon
|
||||||
|
{
|
||||||
|
Id = CouponIDs.Milestone2SubscriptionDiscount,
|
||||||
|
PercentOff = null
|
||||||
|
};
|
||||||
|
|
||||||
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||||
|
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||||
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||||
|
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||||
|
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||||
|
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
||||||
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||||
|
.Returns(subscription);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.HandleAsync(parsedEvent);
|
||||||
|
|
||||||
|
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||||
|
_logger.Received(1).Log(
|
||||||
|
LogLevel.Error,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o =>
|
||||||
|
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
|
||||||
|
o.ToString().Contains(parsedEvent.Id)),
|
||||||
|
Arg.Is<Exception>(e => e is InvalidOperationException
|
||||||
|
&& e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"),
|
||||||
|
Arg.Any<Func<object, Exception, string>>());
|
||||||
|
|
||||||
|
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var parsedEvent = new Event { Id = "evt_123" };
|
||||||
|
var customerId = "cus_123";
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
AmountDue = 10000,
|
||||||
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||||
|
Lines = new StripeList<InvoiceLineItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Description = "Test Item" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = "sub_123",
|
||||||
|
CustomerId = customerId,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||||
|
},
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||||
|
Customer = new Customer { Id = customerId },
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
};
|
||||||
|
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
|
||||||
|
var plan = new PremiumPlan
|
||||||
|
{
|
||||||
|
Name = "Premium",
|
||||||
|
Available = true,
|
||||||
|
LegacyYear = null,
|
||||||
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
||||||
|
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
|
||||||
|
};
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = customerId,
|
||||||
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
|
||||||
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||||
|
};
|
||||||
|
var coupon = new Coupon
|
||||||
|
{
|
||||||
|
Id = CouponIDs.Milestone2SubscriptionDiscount,
|
||||||
|
PercentOff = 30
|
||||||
|
};
|
||||||
|
|
||||||
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||||
|
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||||
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||||
|
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||||
|
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||||
|
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
||||||
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||||
|
.Returns(subscription);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.HandleAsync(parsedEvent);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||||
|
await _mailer.Received(1).SendEmail(
|
||||||
|
Arg.Is<PremiumRenewalMail>(email =>
|
||||||
|
email.ToEmails.Contains("user@example.com") &&
|
||||||
|
email.Subject == "Your Bitwarden Premium renewal is updating" &&
|
||||||
|
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||||
|
email.View.DiscountAmount == "30%" &&
|
||||||
|
email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||||
|
));
|
||||||
|
|
||||||
|
await _mailService.DidNotReceive().SendInvoiceUpcoming(
|
||||||
|
Arg.Any<IEnumerable<string>>(),
|
||||||
|
Arg.Any<decimal>(),
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
Arg.Any<List<string>>(),
|
||||||
|
Arg.Any<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var parsedEvent = new Event { Id = "evt_123" };
|
||||||
|
var customerId = "cus_123";
|
||||||
|
var invoice = new Invoice
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
AmountDue = 10000,
|
||||||
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||||
|
Lines = new StripeList<InvoiceLineItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Description = "Test Item" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = "sub_123",
|
||||||
|
CustomerId = customerId,
|
||||||
|
Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||||
|
},
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||||
|
Customer = new Customer { Id = customerId },
|
||||||
|
Metadata = new Dictionary<string, string>()
|
||||||
|
};
|
||||||
|
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
|
||||||
|
var plan = new PremiumPlan
|
||||||
|
{
|
||||||
|
Name = "Premium",
|
||||||
|
Available = true,
|
||||||
|
LegacyYear = null,
|
||||||
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
||||||
|
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
|
||||||
|
};
|
||||||
|
var customer = new Customer
|
||||||
|
{
|
||||||
|
Id = customerId,
|
||||||
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
|
||||||
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
|
||||||
|
};
|
||||||
|
|
||||||
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||||
|
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||||
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||||
|
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||||
|
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||||
|
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||||
|
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount)
|
||||||
|
.ThrowsAsync(new StripeException("Stripe API error"));
|
||||||
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||||
|
.Returns(subscription);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.HandleAsync(parsedEvent);
|
||||||
|
|
||||||
|
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||||
|
_logger.Received(1).Log(
|
||||||
|
LogLevel.Error,
|
||||||
|
Arg.Any<EventId>(),
|
||||||
|
Arg.Is<object>(o =>
|
||||||
|
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
|
||||||
|
o.ToString().Contains(parsedEvent.Id)),
|
||||||
|
Arg.Is<Exception>(e => e is StripeException),
|
||||||
|
Arg.Any<Func<object, Exception, string>>());
|
||||||
|
|
||||||
|
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user