1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM 19727] Update InvoiceUpcoming email content (#6168)

* changes to implement the email

* Refactoring and fix the unit testing

* refactor the code and remove used method

* Fix the failing test

* Update the email templates

* remove the extra space here

* Refactor the descriptions

* Fix the wrong subject header

* Add the in the hyperlink rather than just Help center
This commit is contained in:
cyprain-okeke
2025-09-03 20:33:32 +05:30
committed by GitHub
parent 1dade9d4b8
commit fa8d65cc1f
10 changed files with 914 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
@@ -18,6 +19,7 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler(
IGetPaymentMethodQuery getPaymentMethodQuery,
ILogger<StripeEventProcessor> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
@@ -137,7 +139,7 @@ public class UpcomingInvoiceHandler(
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id);
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
}
}
@@ -158,6 +160,42 @@ public class UpcomingInvoiceHandler(
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.FormatForProvider(subscription);
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
return;
}
var collectionMethod = subscription.CollectionMethod;
var paymentMethod = await getPaymentMethodQuery.Run(provider);
var hasPaymentMethod = paymentMethod != null;
var paymentMethodDescription = paymentMethod?.Match(
bankAccount => $"Bank account ending in {bankAccount.Last4}",
card => $"{card.Brand} ending in {card.Last4}",
payPal => $"PayPal account {payPal.Email}"
);
await mailService.SendProviderInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
collectionMethod,
hasPaymentMethod,
paymentMethodDescription);
}
}
private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,

View File

@@ -0,0 +1,76 @@
using System.Text.RegularExpressions;
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class InvoiceExtensions
{
/// <summary>
/// Formats invoice line items specifically for provider invoices, standardizing product descriptions
/// and ensuring consistent tax representation.
/// </summary>
/// <param name="invoice">The Stripe invoice containing line items</param>
/// <param name="subscription">The associated subscription (for future extensibility)</param>
/// <returns>A list of formatted invoice item descriptions</returns>
public static List<string> FormatForProvider(this Invoice invoice, Subscription subscription)
{
var items = new List<string>();
// Return empty list if no line items
if (invoice.Lines == null)
{
return items;
}
foreach (var line in invoice.Lines.Data ?? new List<InvoiceLineItem>())
{
// Skip null lines or lines without description
if (line?.Description == null)
{
continue;
}
var description = line.Description;
// Handle Provider Portal and Business Unit Portal service lines
if (description.Contains("Provider Portal") || description.Contains("Business Unit"))
{
var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)");
var priceInfo = priceMatch.Success ? priceMatch.Value : "";
var standardizedDescription = $"{line.Quantity} × Manage service provider {priceInfo}";
items.Add(standardizedDescription);
}
// Handle tax lines
else if (description.ToLower().Contains("tax"))
{
var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)");
var priceInfo = priceMatch.Success ? priceMatch.Value : "";
// If no price info found in description, calculate from amount
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
{
var pricePerItem = (line.Amount / 100m) / line.Quantity;
priceInfo = $"(at ${pricePerItem:F2} / month)";
}
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
items.Add(taxDescription);
}
// Handle other line items as-is
else
{
items.Add(description);
}
}
// Add fallback tax from invoice-level tax if present and not already included
if (invoice.Tax.HasValue && invoice.Tax.Value > 0)
{
var taxAmount = invoice.Tax.Value / 100m;
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
}
return items;
}
}

View File

@@ -0,0 +1,211 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bitwarden</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important; margin: 0;" bgcolor="#f6f6f6">
<style type="text/css">
 body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
body * {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 25px;
}
body {
background-color: #f6f6f6;
}
/* Provider-specific styles */
.provider-header {
background-color: #175DDC;
height: 84px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.provider-content {
border-left: 1px solid #e9e9e9;
border-right: 1px solid #e9e9e9;
border-bottom: 1px solid #e9e9e9;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
@media only screen and (max-width: 600px) {
body {
padding: 0 !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.container-table {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 0 10px 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.main {
border-right: none !important;
border-left: none !important;
border-radius: 0 !important;
}
.provider-header {
border-radius: 0 !important;
}
.provider-content {
border-left: none !important;
border-right: none !important;
border-radius: 0 !important;
}
.logo {
padding-top: 10px !important;
}
.footer {
margin-top: 10px !important;
}
.indented {
padding-left: 10px;
}
}
@media only screen and (min-width: 600px) {
{{! Fix for Apple Mail }}
.content-table {
width: 600px !important;
}
}
/* Component styling - these are explicitly applied via classes so that they can be
gradually introduced as we update templates.*/
a.inline-link {
font-weight: bold;
color: #175DDC;
text-decoration: none;
}
br.line-break {
margin: 0;
box-sizing: border-box;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
</style>
{{! Yahoo center fix }}
<table width="100%" cellpadding="0" cellspacing="0" bgcolor="#f6f6f6">
<tr>
<td class="container" width="100%" align="center">
{{! 600px container }}
<table cellpadding="0" cellspacing="0" width="100%" class="content-table">
<tr>
<td></td> {{! Left column (center fix) }}
<td class="content" align="center" valign="top" width="660" style="padding-bottom: 20px;">
<!-- Blue Header with Logo -->
<table class="provider-header" cellpadding="0" cellspacing="0" width="660" bgcolor="#175DDC" style="background-color: #175DDC; width: 660px; height: 84px; opacity: 1; border-top-left-radius: 4px; border-top-right-radius: 4px;">
<tr>
<td valign="top" style="height: 20.53px; width: 417px; padding-left: 32px; padding-top: 32px;">
<img src="https://assets.bitwarden.com/email/v1/logo-horizontal-white.png" alt="Bitwarden" style="display: block; opacity: 1; width: auto; height: 28px; max-width: 417px;" />
</td>
</tr>
</table>
<!-- Main Content Container -->
<table class="main provider-content" cellpadding="0" cellspacing="0" width="660" style="width: 660px; border-left: 1px solid #e9e9e9; border-right: 1px solid #e9e9e9; border-bottom: 1px solid #e9e9e9; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px;" bgcolor="white">
<tr>
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
{{>@partial-block}}
</td>
</tr>
</table>
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; width: 100%;">
<tr>
<td class="aligncenter social-icons" align="center" style="margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://x.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-x.png" alt="X" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.reddit.com/r/Bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-reddit.png" alt="Reddit" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://community.bitwarden.com/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-discourse.png" alt="CommunityForums" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-github.png" alt="GitHub" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-youtube.png" alt="Youtube" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.linkedin.com/company/bitwarden1/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" alt="LinkedIn" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.facebook.com/bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-facebook.png" alt="Facebook" width="30" height="30" /></a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="content-block" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 12px; font-weight: 400; color: #666666; line-height: 16px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 15px 0 0 0; -webkit-text-size-adjust: none; text-align: center;" valign="top">
&copy; {{CurrentYear}} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA
</td>
</tr>
<tr>
<td class="content-block" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 12px; font-weight: 400; color: #999999; line-height: 16px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 10px 0 0 0; -webkit-text-size-adjust: none; text-align: center;" valign="top">
Always confirm you are on an official Bitwarden domain before logging in:<br/>
<a href="#" style="color: #175DDC; text-decoration: none; font-weight: 700;">bitwarden.com</a> | <a href="#" style="color: #175DDC; text-decoration: none; font-weight: 700;">Learn why we include this</a>
</td>
</tr>
</table>
</td>
<td></td> {{! Right column (center fix) }}
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,89 @@
{{#>ProviderFull}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
{{#if (eq CollectionMethod "send_invoice")}}
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 600; font-size: 24px; line-height: 32px; letter-spacing: 0px; color: #1B2029; margin: 0 0 8px 0;">Your subscription will renew soon</div>
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">On <strong>{{date DueDate 'MMMM dd, yyyy'}}</strong> we'll send you an invoice with a summary of the charges including tax.</div>
{{else}}
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 600; font-size: 24px; line-height: 32px; letter-spacing: 0px; color: #1B2029; margin: 0 0 8px 0;">Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}</div>
{{#if HasPaymentMethod}}
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:</div>
{{else}}
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">To avoid any interruption in service, please add a payment method that can be charged for the following amount:</div>
{{/if}}
{{/if}}
</td>
</tr>
{{#unless (eq CollectionMethod "send_invoice")}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 32px; font-weight: bold; color: #1B2029; line-height: 1.2; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
{{usd AmountDue}}
</td>
</tr>
{{/unless}}
{{#if Items}}
{{#unless (eq CollectionMethod "send_invoice")}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; font-weight: 400; color: #1B2029; line-height: 24px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<strong style="margin: 0; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; font-weight: 700; color: ##1B2029; line-height: 24px; letter-spacing: 0px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Summary Of Charges</strong><br />
<div style="border-bottom: 1px solid #ddd; margin: 5px 0 10px 0; padding-bottom: 5px;"></div>
{{#each Items}}
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">{{this}}</div>
{{/each}}
</td>
</tr>
{{/unless}}
{{/if}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
{{#if (eq CollectionMethod "send_invoice")}}
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.</div>
{{else}}
{{/if}}
</td>
</tr>
{{#unless (eq CollectionMethod "send_invoice")}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<table cellpadding="0" cellspacing="0" style="margin: 0;">
<tr>
<td style="background-color: #175DDC; border-radius: 25px; padding: 12px 24px;">
<a href="{{{UpdateBillingInfoUrl}}}" style="color: #ffffff; text-decoration: none; font-weight: 500; font-size: 16px;">Update payment method</a>
</td>
</tr>
</table>
</td>
</tr>
{{/unless}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 16px; -webkit-text-size-adjust: none;" valign="top">
{{#if (eq CollectionMethod "send_invoice")}}
<table cellpadding="0" cellspacing="0" style="margin: 0;">
<tr>
<td style="background-color: #175DDC; border-radius: 25px; padding: 12px 24px;">
<a href="{{{ContactUrl}}}" style="color: #ffffff; text-decoration: none; font-weight: 500; font-size: 16px;">Contact Bitwarden Support</a>
</td>
</tr>
</table>
{{/if}}
</td>
</tr>
{{#if (eq CollectionMethod "send_invoice")}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; font-weight: 400; color: #1B2029; line-height: 20px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
For assistance managing your subscription, please visit <a href="https://bitwarden.com/help/update-billing-info" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">the Help Center</strong></a> or <a href="https://bitwarden.com/contact/" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">contact Bitwarden Customer Support</strong></a>.
</td>
</tr>
{{/if}}
{{#unless (eq CollectionMethod "send_invoice")}}
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; font-weight: 400; color: #1B2029; line-height: 20px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
For assistance managing your subscription, please visit <a href="https://bitwarden.com/help/update-billing-info" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">the Help Center</strong></a> or <a href="https://bitwarden.com/contact/" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">contact Bitwarden Customer Support</strong></a>.
</td>
</tr>
{{/unless}}
</table>
{{/ProviderFull}}

View File

@@ -0,0 +1,41 @@
{{#>BasicTextLayout}}
{{#if (eq CollectionMethod "send_invoice")}}
Your subscription will renew soon
On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax.
{{else}}
Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}
{{#if HasPaymentMethod}}
To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:
{{else}}
To avoid any interruption in service, please add a payment method that can be charged for the following amount:
{{/if}}
{{usd AmountDue}}
{{/if}}
{{#if Items}}
{{#unless (eq CollectionMethod "send_invoice")}}
Summary Of Charges
------------------
{{#each Items}}
{{this}}
{{/each}}
{{/unless}}
{{/if}}
{{#if (eq CollectionMethod "send_invoice")}}
To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.
Contact Bitwarden Support: {{{ContactUrl}}}
For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/).
{{else}}
{{/if}}
{{#unless (eq CollectionMethod "send_invoice")}}
For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/).
{{/unless}}
{{/BasicTextLayout}}

View File

@@ -10,4 +10,9 @@ public class InvoiceUpcomingViewModel : BaseMailModel
public List<string> Items { get; set; }
public bool MentionInvoices { get; set; }
public string UpdateBillingInfoUrl { get; set; } = "https://bitwarden.com/help/update-billing-info/";
public string CollectionMethod { get; set; }
public bool HasPaymentMethod { get; set; }
public string PaymentMethodDescription { get; set; }
public string HelpUrl { get; set; } = "https://bitwarden.com/help/";
public string ContactUrl { get; set; } = "https://bitwarden.com/contact/";
}

View File

@@ -59,6 +59,14 @@ public interface IMailService
DateTime dueDate,
List<string> items,
bool mentionInvoices);
Task SendProviderInvoiceUpcoming(
IEnumerable<string> emails,
decimal amount,
DateTime dueDate,
List<string> items,
string? collectionMethod,
bool hasPaymentMethod,
string? paymentMethodDescription);
Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices);
Task SendAddedCreditAsync(string email, decimal amount);
Task SendLicenseExpiredAsync(IEnumerable<string> emails, string? organizationName = null);

View File

@@ -478,6 +478,33 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendProviderInvoiceUpcoming(
IEnumerable<string> emails,
decimal amount,
DateTime dueDate,
List<string> items,
string? collectionMethod = null,
bool hasPaymentMethod = true,
string? paymentMethodDescription = null)
{
var message = CreateDefaultMessage("Your upcoming Bitwarden invoice", emails);
var model = new InvoiceUpcomingViewModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
AmountDue = amount,
DueDate = dueDate,
Items = items,
MentionInvoices = false,
CollectionMethod = collectionMethod,
HasPaymentMethod = hasPaymentMethod,
PaymentMethodDescription = paymentMethodDescription
};
await AddMessageContentAsync(message, "ProviderInvoiceUpcoming", model);
message.Category = "ProviderInvoiceUpcoming";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices)
{
var message = CreateDefaultMessage("Payment Failed", email);
@@ -708,6 +735,8 @@ public class HandlebarsMailService : IMailService
Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource);
var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text");
Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource);
var providerFullHtmlLayoutSource = await ReadSourceAsync("Layouts.ProviderFull.html");
Handlebars.RegisterTemplate("ProviderFull", providerFullHtmlLayoutSource);
Handlebars.RegisterHelper("date", (writer, context, parameters) =>
{
@@ -863,6 +892,19 @@ public class HandlebarsMailService : IMailService
writer.WriteSafeString(string.Empty);
}
});
// Equality comparison helper for conditional templates.
Handlebars.RegisterHelper("eq", (context, arguments) =>
{
if (arguments.Length != 2)
{
return false;
}
var value1 = arguments[0]?.ToString();
var value2 = arguments[1]?.ToString();
return string.Equals(value1, value2, StringComparison.OrdinalIgnoreCase);
});
}
public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token)

View File

@@ -137,6 +137,15 @@ public class NoopMailService : IMailService
List<string> items,
bool mentionInvoices) => Task.FromResult(0);
public Task SendProviderInvoiceUpcoming(
IEnumerable<string> emails,
decimal amount,
DateTime dueDate,
List<string> items,
string? collectionMethod = null,
bool hasPaymentMethod = true,
string? paymentMethodDescription = null) => Task.FromResult(0);
public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices)
{
return Task.FromResult(0);

View File

@@ -0,0 +1,394 @@
using Bit.Core.Billing.Extensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Extensions;
public class InvoiceExtensionsTests
{
private static Invoice CreateInvoiceWithLines(params InvoiceLineItem[] lineItems)
{
return new Invoice
{
Lines = new StripeList<InvoiceLineItem>
{
Data = lineItems?.ToList() ?? new List<InvoiceLineItem>()
}
};
}
#region FormatForProvider Tests
[Fact]
public void FormatForProvider_NullLines_ReturnsEmptyList()
{
// Arrange
var invoice = new Invoice
{
Lines = null
};
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void FormatForProvider_EmptyLines_ReturnsEmptyList()
{
// Arrange
var invoice = CreateInvoiceWithLines();
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void FormatForProvider_NullLineItem_SkipsNullLine()
{
// Arrange
var invoice = CreateInvoiceWithLines(null);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void FormatForProvider_LineWithNullDescription_SkipsLine()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem { Description = null, Quantity = 1, Amount = 1000 }
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void FormatForProvider_ProviderPortalTeams_FormatsCorrectly()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Provider Portal - Teams (at $6.00 / month)",
Quantity = 5,
Amount = 3000
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]);
}
[Fact]
public void FormatForProvider_ProviderPortalEnterprise_FormatsCorrectly()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Provider Portal - Enterprise (at $4.00 / month)",
Quantity = 10,
Amount = 4000
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[0]);
}
[Fact]
public void FormatForProvider_ProviderPortalWithoutPriceInfo_FormatsWithoutPrice()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Provider Portal - Teams",
Quantity = 3,
Amount = 1800
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("3 × Manage service provider ", result[0]);
}
[Fact]
public void FormatForProvider_BusinessUnitPortalEnterprise_FormatsCorrectly()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Business Unit Portal - Enterprise (at $5.00 / month)",
Quantity = 8,
Amount = 4000
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("8 × Manage service provider (at $5.00 / month)", result[0]);
}
[Fact]
public void FormatForProvider_BusinessUnitPortalGeneric_FormatsCorrectly()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Business Unit Portal (at $3.00 / month)",
Quantity = 2,
Amount = 600
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("2 × Manage service provider (at $3.00 / month)", result[0]);
}
[Fact]
public void FormatForProvider_TaxLineWithPriceInfo_FormatsCorrectly()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Tax (at $2.00 / month)",
Quantity = 1,
Amount = 200
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("1 × Tax (at $2.00 / month)", result[0]);
}
[Fact]
public void FormatForProvider_TaxLineWithoutPriceInfo_CalculatesPrice()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Tax",
Quantity = 2,
Amount = 400 // $4.00 total, $2.00 per item
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("2 × Tax (at $2.00 / month)", result[0]);
}
[Fact]
public void FormatForProvider_TaxLineWithZeroQuantity_DoesNotCalculatePrice()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Tax",
Quantity = 0,
Amount = 200
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("0 × Tax ", result[0]);
}
[Fact]
public void FormatForProvider_OtherLineItem_ReturnsAsIs()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Some other service",
Quantity = 1,
Amount = 1000
}
);
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("Some other service", result[0]);
}
[Fact]
public void FormatForProvider_InvoiceLevelTax_AddsToResult()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Provider Portal - Teams",
Quantity = 1,
Amount = 600
}
);
invoice.Tax = 120; // $1.20 in cents
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("1 × Manage service provider ", result[0]);
Assert.Equal("1 × Tax (at $1.20 / month)", result[1]);
}
[Fact]
public void FormatForProvider_NoInvoiceLevelTax_DoesNotAddTax()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Provider Portal - Teams",
Quantity = 1,
Amount = 600
}
);
invoice.Tax = null;
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("1 × Manage service provider ", result[0]);
}
[Fact]
public void FormatForProvider_ZeroInvoiceLevelTax_DoesNotAddTax()
{
// Arrange
var invoice = CreateInvoiceWithLines(
new InvoiceLineItem
{
Description = "Provider Portal - Teams",
Quantity = 1,
Amount = 600
}
);
invoice.Tax = 0;
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Single(result);
Assert.Equal("1 × Manage service provider ", result[0]);
}
[Fact]
public void FormatForProvider_ComplexScenario_HandlesAllLineTypes()
{
// Arrange
var lineItems = new StripeList<InvoiceLineItem>();
lineItems.Data = new List<InvoiceLineItem>
{
new InvoiceLineItem
{
Description = "Provider Portal - Teams (at $6.00 / month)", Quantity = 5, Amount = 3000
},
new InvoiceLineItem
{
Description = "Provider Portal - Enterprise (at $4.00 / month)", Quantity = 10, Amount = 4000
},
new InvoiceLineItem { Description = "Tax", Quantity = 1, Amount = 800 },
new InvoiceLineItem { Description = "Custom Service", Quantity = 2, Amount = 2000 }
};
var invoice = new Invoice
{
Lines = lineItems,
Tax = 200 // Additional $2.00 tax
};
var subscription = new Subscription();
// Act
var result = invoice.FormatForProvider(subscription);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]);
Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]);
Assert.Equal("1 × Tax (at $8.00 / month)", result[2]);
Assert.Equal("Custom Service", result[3]);
Assert.Equal("1 × Tax (at $2.00 / month)", result[4]);
}
#endregion
}