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:
@@ -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,
|
||||
|
||||
76
src/Core/Billing/Extensions/InvoiceExtensions.cs
Normal file
76
src/Core/Billing/Extensions/InvoiceExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
211
src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs
Normal file
211
src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs
Normal 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">
|
||||
© {{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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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/";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
394
test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs
Normal file
394
test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user