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

[PM-24273] Milestone 2C (#6544)

* feat(billing): add mjml template and updated templates

* feat(billing): update maileservices

* feat(billing): add milestone2 discount

* feat(billing): add milestone 2 updates and stripe constants

* tests(billing): add handler tests

* fix(billing): update mailer view and templates

* fix(billing): revert mailservice changes

* fix(billing): swap mailer service in handler

* test(billing): update handler tests
This commit is contained in:
Stephon Brown
2025-11-06 13:21:29 -05:00
committed by GitHub
parent 087c6915e7
commit 5dbce33f74
7 changed files with 1089 additions and 10 deletions

View File

@@ -2,18 +2,22 @@
#nullable disable
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
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.Entities;
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Stripe;
using static Bit.Core.Billing.Constants.StripeConstants;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -29,7 +33,9 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand)
IValidateSponsorshipCommand validateSponsorshipCommand,
IMailer mailer,
IFeatureService featureService)
: IUpcomingInvoiceHandler
{
public async Task HandleAsync(Event parsedEvent)
@@ -37,7 +43,8 @@ public class UpcomingInvoiceHandler(
var invoice = await stripeEventService.GetInvoice(parsedEvent);
var customer =
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
await stripeFacade.GetCustomer(invoice.CustomerId,
new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
var subscription = customer.Subscriptions.FirstOrDefault();
@@ -68,7 +75,8 @@ public class UpcomingInvoiceHandler(
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
if (!sponsorshipIsValid)
{
@@ -122,9 +130,17 @@ public class UpcomingInvoiceHandler(
}
}
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user);
}
if (user.Premium)
{
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
await (milestone2Feature
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
}
}
else if (providerId.HasValue)
@@ -142,6 +158,39 @@ public class UpcomingInvoiceHandler(
}
}
private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user)
{
var pricingItem =
subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (pricingItem != null)
{
try
{
var plan = await pricingClient.GetAvailablePremiumPlan();
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
]
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
}
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
@@ -159,7 +208,19 @@ public class UpcomingInvoiceHandler(
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
{
ToEmails = validEmails,
View = new UpdatedInvoiceUpcomingView()
};
await mailer.SendEmail(updatedUpcomingEmail);
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
@@ -205,12 +266,12 @@ public class UpcomingInvoiceHandler(
organization.PlanType.GetProductTier() != ProductTierType.Families &&
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
}
catch (Exception exception)
{
@@ -250,12 +311,12 @@ public class UpcomingInvoiceHandler(
string eventId)
{
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
customer.TaxExempt != TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
}
catch (Exception exception)
{

View File

@@ -22,6 +22,7 @@ public static class StripeConstants
{
public const string LegacyMSPDiscount = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone";
public const string Milestone2SubscriptionDiscount = "cm3nHfO1";
public static class MSPDiscounts
{

View File

@@ -0,0 +1,27 @@
<mjml>
<mj-head>
<mj-include path="../components/head.mjml" />
</mj-head>
<mj-body background-color="#f6f6f6">
<mj-include path="../components/logo.mjml" />
<mj-wrapper
background-color="#fff"
border="1px solid #e9e9e9"
css-class="border-fix"
padding="0"
>
<mj-section>
<mj-column>
<mj-text>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-include path="../components/footer.mjml" />
</mj-body>
</mjml>

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{{#>BasicTextLayout}}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.
{{/BasicTextLayout}}

View File

@@ -0,0 +1,947 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Pricing.Premium;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Stripe;
using Xunit;
using static Bit.Core.Billing.Constants.StripeConstants;
using Address = Stripe.Address;
using Event = Stripe.Event;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
namespace Bit.Billing.Test.Services;
public class UpcomingInvoiceHandlerTests
{
private readonly IGetPaymentMethodQuery _getPaymentMethodQuery;
private readonly ILogger<StripeEventProcessor> _logger;
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPricingClient _pricingClient;
private readonly IProviderRepository _providerRepository;
private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IUserRepository _userRepository;
private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
private readonly IMailer _mailer;
private readonly IFeatureService _featureService;
private readonly UpcomingInvoiceHandler _sut;
private readonly Guid _userId = Guid.NewGuid();
private readonly Guid _organizationId = Guid.NewGuid();
private readonly Guid _providerId = Guid.NewGuid();
public UpcomingInvoiceHandlerTests()
{
_getPaymentMethodQuery = Substitute.For<IGetPaymentMethodQuery>();
_logger = Substitute.For<ILogger<StripeEventProcessor>>();
_mailService = Substitute.For<IMailService>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_pricingClient = Substitute.For<IPricingClient>();
_providerRepository = Substitute.For<IProviderRepository>();
_stripeFacade = Substitute.For<IStripeFacade>();
_stripeEventService = Substitute.For<IStripeEventService>();
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
_userRepository = Substitute.For<IUserRepository>();
_validateSponsorshipCommand = Substitute.For<IValidateSponsorshipCommand>();
_mailer = Substitute.For<IMailer>();
_featureService = Substitute.For<IFeatureService>();
_sut = new UpcomingInvoiceHandler(
_getPaymentMethodQuery,
_logger,
_mailService,
_organizationRepository,
_pricingClient,
_providerRepository,
_stripeFacade,
_stripeEventService,
_stripeEventUtilityService,
_userRepository,
_validateSponsorshipCommand,
_mailer,
_featureService);
}
[Fact]
public async Task HandleAsync_WhenNullSubscription_DoesNothing()
{
// Arrange
var parsedEvent = new Event();
var invoice = new Invoice { CustomerId = "cus_123" };
var customer = new Customer { Id = "cus_123", Subscriptions = new StripeList<Subscription> { Data = [] } };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>())
.Returns(customer);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.DidNotReceive()
.UpdateCustomer(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Fact]
public async Task HandleAsync_WhenValidUser_SendsEmail()
{
// 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 List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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);
// If milestone 2 is disabled, the default email is sent
_featureService
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
.Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
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_WhenUserValid_AndMilestone2Enabled_UpdatesPriceId_AndSendsUpdatedInvoiceUpcomingEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var priceSubscriptionId = "sub-1";
var priceId = "price-id-2";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }
},
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 = priceId },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { 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);
_stripeFacade.UpdateSubscription(
subscription.Id,
Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// If milestone 2 is true, the updated invoice email is sent
_featureService
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
await _pricingClient.Received(1).GetAvailablePremiumPlan();
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is("sub_123"),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items[0].Id == priceSubscriptionId &&
o.Items[0].Price == priceId));
// Verify the updated invoice email was sent
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
LatestInvoiceId = "inv_latest"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
var plan = new FamiliesPlan();
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.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(organization.PlanType)
.Returns(plan);
_stripeEventUtilityService
.IsSponsoredSubscription(subscription)
.Returns(true);
// Configure that this is a sponsored subscription
_stripeEventUtilityService
.IsSponsoredSubscription(subscription)
.Returns(true);
_validateSponsorshipCommand
.ValidateSponsorshipAsync(_organizationId)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.Received(1).GetByIdAsync(_organizationId);
await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId);
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_WhenOrganizationHasSponsorship_ButInvalidSponsorship_RetrievesUpdatedInvoice_SendsEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[new SubscriptionItem { Price = new Price { Id = "2021-family-for-enterprise-annually" } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
LatestInvoiceId = "inv_latest"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
var plan = new FamiliesPlan();
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.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(organization.PlanType)
.Returns(plan);
// Configure that this is not a sponsored subscription
_stripeEventUtilityService
.IsSponsoredSubscription(subscription)
.Returns(true);
// Validate sponsorship should return false
_validateSponsorshipCommand
.ValidateSponsorshipAsync(_organizationId)
.Returns(false);
_stripeFacade
.GetInvoice(subscription.LatestInvoiceId)
.Returns(invoice);
_getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.Received(1).GetByIdAsync(_organizationId);
_stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription);
await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId);
await _stripeFacade.Received(1).GetInvoice(Arg.Is("inv_latest"));
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_WhenValidOrganization_SendsEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[new SubscriptionItem { Price = new Price { Id = "enterprise-annually" } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
LatestInvoiceId = "inv_latest"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.EnterpriseAnnually
};
var plan = new FamiliesPlan();
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.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(organization.PlanType)
.Returns(plan);
_stripeEventUtilityService
.IsSponsoredSubscription(subscription)
.Returns(false);
_stripeFacade
.GetInvoice(subscription.LatestInvoiceId)
.Returns(invoice);
_getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.Received(1).GetByIdAsync(_organizationId);
_stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription);
// Should not validate sponsorship for non-sponsored subscription
await _validateSponsorshipCommand.DidNotReceive().ValidateSponsorshipAsync(Arg.Any<Guid>());
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_WhenValidProviderSubscription_SendsEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
CollectionMethod = "charge_automatically"
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "UK" },
TaxExempt = TaxExempt.None
};
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, _providerId));
_providerRepository.GetByIdAsync(_providerId).Returns(provider);
_getPaymentMethodQuery.Run(provider).Returns(MaskedPaymentMethod.From(paymentMethod));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _providerRepository.Received(2).GetByIdAsync(_providerId);
// Verify tax exempt was set to reverse for non-US providers
await _stripeFacade.Received(1).UpdateCustomer(
Arg.Is("cus_123"),
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
// Verify automatic tax was enabled
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is("sub_123"),
Arg.Is<SubscriptionUpdateOptions>(o => o.AutomaticTax.Enabled == true));
// Verify provider invoice email was sent
await _mailService.Received(1).SendProviderInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(e => e.Contains("provider@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<string>(s => s == subscription.CollectionMethod),
Arg.Is<bool>(b => b == true),
Arg.Is<string>(s => s == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}"));
}
[Fact]
public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail()
{
// Arrange
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var priceSubscriptionId = "sub-1";
var priceId = "price-id-2";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }
},
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 = priceId },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_featureService
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
.Returns(true);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
// Setup exception when updating subscription
_stripeFacade
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.ThrowsAsync(new Exception());
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_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 while processing event with ID {parsedEvent.Id}")),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Verify that email was still sent despite the exception
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>())
.Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
// Organization not found
_organizationRepository.GetByIdAsync(_organizationId).Returns((Organization)null);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _organizationRepository.Received(1).GetByIdAsync(_organizationId);
// Verify no emails were sent
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_WhenZeroAmountInvoice_DoesNothing()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 0, // Zero amount due
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Free Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>())
.Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
// Should not
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_WhenUserNotFound_DoesNothing()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>())
.Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
// User not found
_userRepository.GetByIdAsync(_userId).Returns((User)null);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
// Verify no emails were sent
await _mailService.DidNotReceive().SendInvoiceUpcoming(
Arg.Any<IEnumerable<string>>(),
Arg.Any<decimal>(),
Arg.Any<DateTime>(),
Arg.Any<List<string>>(),
Arg.Any<bool>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<UpdatedInvoiceUpcomingMail>());
}
[Fact]
public async Task HandleAsync_WhenProviderNotFound_DoesNothing()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var invoice = new Invoice
{
CustomerId = "cus_123",
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(invoice.CustomerId, Arg.Any<CustomerGetOptions>())
.Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, _providerId));
// Provider not found
_providerRepository.GetByIdAsync(_providerId).Returns((Provider)null);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _providerRepository.Received(1).GetByIdAsync(_providerId);
// Verify no provider emails were sent
await _mailService.DidNotReceive().SendProviderInvoiceUpcoming(
Arg.Any<IEnumerable<string>>(),
Arg.Any<decimal>(),
Arg.Any<DateTime>(),
Arg.Any<List<string>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<string>());
}
}