1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00
Files
server/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs
Kyle Denney 89a2eab32a [PM-23717] premium renewal email (#6672)
* [PM-23717] premium renewal email

* pr feedback

* pr feedback
2025-12-02 16:38:28 -06:00

2531 lines
101 KiB
C#

using System.Globalization;
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.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.Billing.Renewal.Families2019Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Mocks.Plans;
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() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade
.GetCustomer(customerId, Arg.Any<CustomerGetOptions>())
.Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
// 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() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [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 = [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);
var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount };
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
await _pricingClient.Received(1).GetAvailablePremiumPlan();
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is("sub_123"),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items[0].Id == priceSubscriptionId &&
o.Items[0].Price == priceId &&
o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount &&
o.ProrationBehavior == "none"));
// Verify the updated invoice email was sent with correct price
var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
await _mailer.Received(1).SendEmail(
Arg.Is<PremiumRenewalMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Bitwarden Premium renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[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() { 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 = [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() { 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 = [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() { 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 = [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() { 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 = [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_LogsErrorAndSendsTraditionalEmail()
{
// 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() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [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 = [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 traditional email was sent when update fails
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
// Verify renewal email was NOT sent
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
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() { 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 = [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() { 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 = [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() { 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 = [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<Families2020RenewalMail>());
}
[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() { 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 = [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>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var premiumAccessItemId = "si_premium_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == premiumAccessItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNotUpdateSubscription()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription or organization when feature flag is disabled
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } }
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually // Already on the new plan
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription when not on FamiliesAnnually2019 plan
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new() { Id = "si_different_item", Price = new Price { Id = "different-price-id" } }
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should not update subscription or organization when password manager item not found
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Simulate update failure
_stripeFacade
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.ThrowsAsync(new Exception("Stripe API error"));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should send traditional email when update fails
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
// Verify renewal email was NOT sent
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone3SubscriptionDiscount,
PercentOff = null
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var seatAddOnItemId = "si_seat_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 3
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == seatAddOnItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_DeletesItem()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var seatAddOnItemId = "si_seat_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 1
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == seatAddOnItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddOn_UpdatesBothItems()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var premiumAccessItemId = "si_premium_123";
var seatAddOnItemId = "si_seat_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 2
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 3 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == premiumAccessItemId &&
o.Items[1].Deleted == true &&
o.Items[2].Id == seatAddOnItemId &&
o.Items[2].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesSubscriptionOnlyNoAddons()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2025Plan = new Families2025Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2025
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Discounts == null &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNotUpdateSubscription()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2025Plan = new Families2025Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2025
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription or organization when feature flag is disabled
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
}
#region Premium Renewal Email Tests
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException
&& e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = null
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException
&& e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 30
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
await _mailer.Received(1).SendEmail(
Arg.Is<PremiumRenewalMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Bitwarden Premium renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == "30%" &&
email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US"))
));
await _mailService.DidNotReceive().SendInvoiceUpcoming(
Arg.Any<IEnumerable<string>>(),
Arg.Any<decimal>(),
Arg.Any<DateTime>(),
Arg.Any<List<string>>(),
Arg.Any<bool>());
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount)
.ThrowsAsync(new StripeException("Stripe API error"));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is StripeException),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
#endregion
}