mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 01:55:55 -05:00
* feat(billing): introduce unified subscription price increase scheduler API * feat(billing): implement unified subscription price increase scheduler logic * refactor(billing): update subscription handlers to use unified scheduler * feat(billing): extend price migration feature flag checks * test(billing): add and update tests for unified price increase scheduler * fix(billing): run dotnet format * feat(billing): expand customer and customer.discount on subscription fetch * refactor(ReinstateSubscriptionCommandTests): rename test method for broader scope * feat(billing): expand customer.discount in update handler * test(billing): update test name * feat(billing): add test clock waiting mechanism for upcoming invoices * feat(billing): introduce cancelling user ID metadata key * feat(billing): store cancelling user ID on subscription cancellation * feat(billing): clear cancelling user ID on subscription reinstatement * test(billing): update subscriber service tests for cancelling user ID * style(SubscriberService): use 'is not null' pattern matching * feat(SubscriberService): add PM35215 migration cohort metadata handling * feat(SubscriberService): extend price migration deferral to PM35215 * test(SubscriberService): add and update tests for PM35215 feature * feat(billing): Introduce OrganizationPriceIncreaseOptions * refactor(billing): Centralize price increase eligibility in scheduler * refactor(billing): Delegate price increase validation from UpcomingInvoiceHandler * feat(billing): Manage price increase schedules during subscription lifecycle events * test(billing): Update UpcomingInvoiceHandlerTests for centralized validation * test(billing): Add PriceIncreaseScheduler tests for SkipIfAlreadyScheduled option * test(billing): Add SubscriberService tests for price increase schedule management * fix(billing): run dotnet format * fix(billing): remove redundant customer expansion * fix(billing): expand discounts for customer and subscription * refactor(billing): Rename method to clarify dispatching role for organization scheduling * fix(billing): Prevent clearing migration cohort metadata on cancellation * fix(billing): Fallback to standard email when price increase migration fails * feat(billing): improve observability for missing migration path data * refactor(billing): simplify business plan type identification
3999 lines
169 KiB
C#
3999 lines
169 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.Organizations.PlanMigration.Entities;
|
|
using Bit.Core.Billing.Organizations.PlanMigration.Enums;
|
|
using Bit.Core.Billing.Organizations.PlanMigration.Repositories;
|
|
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.Billing.Services;
|
|
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 IOrganizationPlanMigrationCohortAssignmentRepository _assignmentRepository;
|
|
private readonly IOrganizationPlanMigrationCohortRepository _cohortRepository;
|
|
private readonly IOrganizationRepository _organizationRepository;
|
|
private readonly IPricingClient _pricingClient;
|
|
private readonly IProviderRepository _providerRepository;
|
|
private readonly IStripeAdapter _stripeAdapter;
|
|
private readonly IPriceIncreaseScheduler _priceIncreaseScheduler;
|
|
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>();
|
|
_assignmentRepository = Substitute.For<IOrganizationPlanMigrationCohortAssignmentRepository>();
|
|
_cohortRepository = Substitute.For<IOrganizationPlanMigrationCohortRepository>();
|
|
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
|
_pricingClient = Substitute.For<IPricingClient>();
|
|
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan>());
|
|
_providerRepository = Substitute.For<IProviderRepository>();
|
|
_stripeAdapter = Substitute.For<IStripeAdapter>();
|
|
_priceIncreaseScheduler = Substitute.For<IPriceIncreaseScheduler>();
|
|
_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,
|
|
_assignmentRepository,
|
|
_cohortRepository,
|
|
_organizationRepository,
|
|
_pricingClient,
|
|
_providerRepository,
|
|
_stripeAdapter,
|
|
_priceIncreaseScheduler,
|
|
_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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(invoice.CustomerId, Arg.Any<CustomerGetOptions>())
|
|
.Returns(customer);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.DidNotReceive()
|
|
.UpdateCustomerAsync(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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
|
|
// 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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
|
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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
_stripeAdapter.UpdateSubscriptionAsync(
|
|
subscription.Id,
|
|
Arg.Any<SubscriptionUpdateOptions>())
|
|
.Returns(subscription);
|
|
|
|
var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount };
|
|
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _userRepository.Received(1).GetByIdAsync(_userId);
|
|
await _pricingClient.Received(1).ListPremiumPlans();
|
|
await _stripeAdapter.Received(1).GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount);
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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.DiscountedAnnualRenewalPrice == discountedPrice.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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
_stripeAdapter
|
|
.GetInvoiceAsync(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 _stripeAdapter.Received(1).GetInvoiceAsync(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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
|
|
_stripeAdapter
|
|
.GetInvoiceAsync(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_WhenNonDirectTaxCountryOrganization_SetsReverseCharge()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = "cus_123" },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "DE" },
|
|
TaxExempt = TaxExempt.None
|
|
};
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new EnterprisePlan(isAnnual: true));
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
Arg.Is("cus_123"),
|
|
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenUSOrganizationWithManualReverseCharge_CorrectsTaxExemptToNone()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = "cus_123" },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "US" },
|
|
TaxExempt = TaxExempt.Reverse
|
|
};
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new EnterprisePlan(isAnnual: true));
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
Arg.Is("cus_123"),
|
|
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenSwissOrganizationWithReverse_CorrectsTaxExemptToNone()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = "cus_123" },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "CH" },
|
|
TaxExempt = TaxExempt.Reverse
|
|
};
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new EnterprisePlan(isAnnual: true));
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
"cus_123",
|
|
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenOrganizationCustomerIsExempt_DoesNotUpdateTaxExemption()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = "cus_123" },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "DE" },
|
|
TaxExempt = TaxExempt.Exempt
|
|
};
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new EnterprisePlan(isAnnual: true));
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Any<CustomerUpdateOptions>());
|
|
}
|
|
|
|
[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);
|
|
_stripeAdapter.GetCustomerAsync(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-direct-tax-country providers
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
Arg.Is("cus_123"),
|
|
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
|
|
|
|
// Verify automatic tax was enabled
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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_WhenSwissProviderWithReverse_CorrectsTaxExemptToNone()
|
|
{
|
|
// 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 = true },
|
|
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 = "CH" },
|
|
TaxExempt = TaxExempt.Reverse
|
|
};
|
|
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
|
|
|
|
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
"cus_123",
|
|
Arg.Is<CustomerUpdateOptions>(options => options.TaxExempt == TaxExempt.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenProviderCustomerIsExempt_DoesNotUpdateTaxExemption()
|
|
{
|
|
// 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 = true },
|
|
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 = "DE" },
|
|
TaxExempt = TaxExempt.Exempt
|
|
};
|
|
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
|
|
var paymentMethod = new Card { Last4 = "4242", Brand = "visa" };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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 _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Any<CustomerUpdateOptions>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenNonDirectTaxCountryProvider_SetsReverseCharge()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
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 = "DE" },
|
|
TaxExempt = TaxExempt.None
|
|
};
|
|
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
Arg.Is("cus_123"),
|
|
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.Reverse));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenUSProviderWithManualReverseCharge_CorrectsTaxExemptToNone()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
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 = "US" },
|
|
TaxExempt = TaxExempt.Reverse
|
|
};
|
|
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateCustomerAsync(
|
|
Arg.Is("cus_123"),
|
|
Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt == TaxExempt.None));
|
|
}
|
|
|
|
[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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
|
|
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
|
|
// Setup exception when updating subscription
|
|
_stripeAdapter
|
|
.UpdateSubscriptionAsync(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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
_stripeAdapter
|
|
.GetCustomerAsync(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);
|
|
_stripeAdapter.GetCustomerAsync(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
|
_stripeAdapter.GetCouponAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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 _stripeAdapter.Received(1).GetCouponAsync(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 subscription 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}%"
|
|
));
|
|
|
|
// Families plan is excluded from tax exempt alignment
|
|
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Any<CustomerUpdateOptions>());
|
|
}
|
|
|
|
[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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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));
|
|
|
|
// Families plan is excluded from tax exempt alignment
|
|
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Any<CustomerUpdateOptions>());
|
|
}
|
|
|
|
[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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert - should not update subscription when not on FamiliesAnnually2019 plan
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
|
|
|
|
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
|
// Families plan is excluded from tax exempt alignment
|
|
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Any<CustomerUpdateOptions>());
|
|
}
|
|
|
|
[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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_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 _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Simulate update failure
|
|
_stripeAdapter
|
|
.UpdateSubscriptionAsync(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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null);
|
|
_stripeAdapter.UpdateSubscriptionAsync(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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
|
_stripeAdapter.UpdateSubscriptionAsync(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);
|
|
_stripeAdapter.GetCustomerAsync(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
|
_stripeAdapter.GetCouponAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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 _stripeAdapter.Received(1).GetCouponAsync(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 subscription 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);
|
|
_stripeAdapter.GetCustomerAsync(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
|
_stripeAdapter.GetCouponAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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 _stripeAdapter.Received(1).GetCouponAsync(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 subscription 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);
|
|
_stripeAdapter.GetCustomerAsync(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
|
_stripeAdapter.GetCouponAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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 _stripeAdapter.Received(1).GetCouponAsync(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 subscription 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);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
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_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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null);
|
|
_stripeAdapter.UpdateSubscriptionAsync(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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
|
_stripeAdapter.UpdateSubscriptionAsync(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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
|
_stripeAdapter.UpdateSubscriptionAsync(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.DiscountedAnnualRenewalPrice == expectedDiscountedPrice.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 oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount)
|
|
.ThrowsAsync(new StripeException("Stripe API error"));
|
|
_stripeAdapter.UpdateSubscriptionAsync(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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_Premium_DeferEnabled_CallsScheduler()
|
|
{
|
|
// 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 };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = subscriptionId,
|
|
CustomerId = customerId,
|
|
Items = new StripeList<SubscriptionItem>
|
|
{
|
|
Data =
|
|
[
|
|
new() { Id = "si_premium_123", Price = new Price { Id = Prices.PremiumAnnually }, Quantity = 1 }
|
|
]
|
|
},
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
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 = "premium-annually-2025" },
|
|
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
|
|
};
|
|
var oldPlan = new PremiumPlan
|
|
{
|
|
Name = "Premium (Old)",
|
|
Available = false,
|
|
LegacyYear = 2023,
|
|
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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.ListPremiumPlans().Returns(new List<PremiumPlan> { oldPlan, plan });
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone2SubscriptionDiscount)
|
|
.Returns(new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount });
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1).SchedulePersonalPriceIncrease(subscription);
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
|
|
Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_Families_DeferEnabled_CallsScheduler()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var customerId = "cus_123";
|
|
var subscriptionId = "sub_123";
|
|
|
|
var families2019Plan = new Families2019Plan();
|
|
|
|
var subscription = new Subscription
|
|
{
|
|
Id = subscriptionId,
|
|
CustomerId = customerId,
|
|
Items = new StripeList<SubscriptionItem>
|
|
{
|
|
Data =
|
|
[
|
|
new()
|
|
{
|
|
Id = "si_pm_123",
|
|
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId },
|
|
Quantity = 1
|
|
}
|
|
]
|
|
},
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var invoice = new Invoice { CustomerId = customerId };
|
|
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);
|
|
_stripeAdapter.GetCustomerAsync(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(new FamiliesPlan());
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_stripeAdapter.GetCouponAsync(CouponIDs.Milestone3SubscriptionDiscount)
|
|
.Returns(new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount });
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1).SchedulePersonalPriceIncrease(subscription);
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
|
|
Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
|
Items = new StripeList<SubscriptionItem> { Data = [] },
|
|
Metadata = new Dictionary<string, string> { { "organizationId", _organizationId.ToString() } }
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "US" }
|
|
};
|
|
var organization = new Organization { Id = _organizationId, PlanType = PlanType.TeamsAnnually, BillingEmail = "test@test.com" };
|
|
|
|
var phase1Start = DateTime.UtcNow.AddDays(-10);
|
|
var phase1End = DateTime.UtcNow.AddDays(5);
|
|
var phase2End = DateTime.UtcNow.AddDays(370);
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new TeamsPlan(isAnnual: true));
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
|
|
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
|
|
.Returns(new StripeList<SubscriptionSchedule>
|
|
{
|
|
Data =
|
|
[
|
|
new SubscriptionSchedule
|
|
{
|
|
Id = "sub_sched_123",
|
|
SubscriptionId = "sub_123",
|
|
Status = SubscriptionScheduleStatus.Active,
|
|
Phases = new List<SubscriptionSchedulePhase>
|
|
{
|
|
new()
|
|
{
|
|
StartDate = phase1Start,
|
|
EndDate = phase1End,
|
|
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_old", Quantity = 1 }],
|
|
Discounts = [],
|
|
ProrationBehavior = "none"
|
|
},
|
|
new()
|
|
{
|
|
StartDate = phase1End,
|
|
EndDate = phase2End,
|
|
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_new", Quantity = 1 }],
|
|
Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "milestone-coupon" }],
|
|
ProrationBehavior = "none"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert — schedule updated with phases and default_settings
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(
|
|
Arg.Is("sub_sched_123"),
|
|
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
|
|
o.DefaultSettings.AutomaticTax.Enabled == true &&
|
|
o.Phases.Count == 2 &&
|
|
o.Phases[0].AutomaticTax.Enabled == true &&
|
|
o.Phases[0].Items[0].Price == "price_old" &&
|
|
o.Phases[1].AutomaticTax.Enabled == true &&
|
|
o.Phases[1].Items[0].Price == "price_new" &&
|
|
o.Phases[1].Discounts[0].Coupon == "milestone-coupon"));
|
|
|
|
// Assert — subscription NOT updated directly for tax
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
|
|
Arg.Is("sub_123"), Arg.Is<SubscriptionUpdateOptions>(o => o.AutomaticTax != null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_NoSchedule_UpdatesSubscriptionDirectly()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
|
Items = new StripeList<SubscriptionItem> { Data = [] },
|
|
Metadata = new Dictionary<string, string> { { "organizationId", _organizationId.ToString() } }
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "US" }
|
|
};
|
|
var organization = new Organization { Id = _organizationId, PlanType = PlanType.TeamsAnnually, BillingEmail = "test@test.com" };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new TeamsPlan(isAnnual: true));
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
|
|
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
|
|
.Returns(new StripeList<SubscriptionSchedule> { Data = new List<SubscriptionSchedule>() });
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert — subscription updated directly
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
Arg.Is("sub_123"),
|
|
Arg.Is<SubscriptionUpdateOptions>(o => o.AutomaticTax.Enabled == true));
|
|
|
|
// Assert — schedule NOT updated
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync(
|
|
Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
|
Items = new StripeList<SubscriptionItem> { Data = [] },
|
|
Metadata = new Dictionary<string, string> { { "userId", _userId.ToString() } }
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }
|
|
};
|
|
var user = new User { Id = _userId, Email = "test@test.com", Premium = true };
|
|
|
|
var phase1Start = DateTime.UtcNow.AddDays(-10);
|
|
var phase1End = DateTime.UtcNow.AddDays(5);
|
|
var phase2End = DateTime.UtcNow.AddDays(370);
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
|
|
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
|
|
.Returns(new StripeList<SubscriptionSchedule>
|
|
{
|
|
Data =
|
|
[
|
|
new SubscriptionSchedule
|
|
{
|
|
Id = "sub_sched_456",
|
|
SubscriptionId = "sub_123",
|
|
Status = SubscriptionScheduleStatus.Active,
|
|
Phases = new List<SubscriptionSchedulePhase>
|
|
{
|
|
new()
|
|
{
|
|
StartDate = phase1Start,
|
|
EndDate = phase1End,
|
|
Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-annually", Quantity = 1 }],
|
|
Discounts = [],
|
|
ProrationBehavior = "none"
|
|
},
|
|
new()
|
|
{
|
|
StartDate = phase1End,
|
|
EndDate = phase2End,
|
|
Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-annually-new", Quantity = 1 }],
|
|
Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "milestone-2c" }],
|
|
ProrationBehavior = "none"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert — schedule updated with phases and default_settings
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(
|
|
Arg.Is("sub_sched_456"),
|
|
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
|
|
o.DefaultSettings.AutomaticTax.Enabled == true &&
|
|
o.Phases.Count == 2 &&
|
|
o.Phases[0].AutomaticTax.Enabled == true &&
|
|
o.Phases[0].Items[0].Price == "premium-annually" &&
|
|
o.Phases[1].AutomaticTax.Enabled == true &&
|
|
o.Phases[1].Items[0].Price == "premium-annually-new" &&
|
|
o.Phases[1].Discounts[0].Coupon == "milestone-2c"));
|
|
|
|
// Assert — subscription NOT updated directly for tax
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
|
|
Arg.Is("sub_123"), Arg.Is<SubscriptionUpdateOptions>(o => o.AutomaticTax != null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_NoSchedule_UpdatesSubscriptionDirectly()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
|
Items = new StripeList<SubscriptionItem> { Data = [] },
|
|
Metadata = new Dictionary<string, string> { { "userId", _userId.ToString() } }
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }
|
|
};
|
|
var user = new User { Id = _userId, Email = "test@test.com", Premium = true };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
|
|
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
|
|
.Returns(new StripeList<SubscriptionSchedule> { Data = new List<SubscriptionSchedule>() });
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert — subscription updated directly
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
|
Arg.Is("sub_123"),
|
|
Arg.Is<SubscriptionUpdateOptions>(o => o.AutomaticTax.Enabled == true));
|
|
|
|
// Assert — schedule NOT updated
|
|
await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync(
|
|
Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenTaxNotEnabled_FlagOn_Phase2Active_SkipsCompletedPhaseAndClearsConsumedDiscounts()
|
|
{
|
|
// Arrange — Phase 1 has ended, Phase 2 is now the active phase.
|
|
// Phase 2's one-time migration discount was consumed at transition and must not be re-included.
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
|
Items = new StripeList<SubscriptionItem> { Data = [] },
|
|
Metadata = new Dictionary<string, string> { { "userId", _userId.ToString() } }
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }
|
|
};
|
|
var user = new User { Id = _userId, Email = "test@test.com", Premium = true };
|
|
|
|
// Phase 1 ended yesterday, Phase 2 active now
|
|
var phase1Start = DateTime.UtcNow.AddDays(-375);
|
|
var phase1End = DateTime.UtcNow.AddDays(-1);
|
|
var phase2End = DateTime.UtcNow.AddDays(364);
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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.PM32645_DeferPriceMigrationToRenewal).Returns(true);
|
|
|
|
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
|
|
.Returns(new StripeList<SubscriptionSchedule>
|
|
{
|
|
Data =
|
|
[
|
|
new SubscriptionSchedule
|
|
{
|
|
Id = "sub_sched_789",
|
|
SubscriptionId = "sub_123",
|
|
Status = SubscriptionScheduleStatus.Active,
|
|
Phases = new List<SubscriptionSchedulePhase>
|
|
{
|
|
new()
|
|
{
|
|
StartDate = phase1Start,
|
|
EndDate = phase1End,
|
|
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_old", Quantity = 1 }],
|
|
Discounts = [],
|
|
ProrationBehavior = "none"
|
|
},
|
|
new()
|
|
{
|
|
StartDate = phase1End,
|
|
EndDate = phase2End,
|
|
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_new", Quantity = 1 }],
|
|
Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "milestone-2c" }],
|
|
ProrationBehavior = "none"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert — schedule updated: Phase 1 skipped, Phase 2 included with cleared discounts
|
|
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(
|
|
Arg.Is("sub_sched_789"),
|
|
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
|
|
o.DefaultSettings.AutomaticTax.Enabled == true &&
|
|
o.Phases.Count == 1 &&
|
|
o.Phases[0].AutomaticTax.Enabled == true &&
|
|
o.Phases[0].Items[0].Price == "price_new" &&
|
|
o.Phases[0].Discounts.Count == 0));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_FlagOn_OrganizationWithMismatchedTaxExempt_DoesNotUpdateCustomerTaxExempt()
|
|
{
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM37597_AlwaysEnableStripeAutomaticTax).Returns(true);
|
|
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = "cus_123" },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "DE" },
|
|
TaxExempt = TaxExempt.None
|
|
};
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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(new EnterprisePlan(isAnnual: true));
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(), Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt != null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_FlagOn_ProviderWithMismatchedTaxExempt_DoesNotUpdateCustomerTaxExempt()
|
|
{
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM37597_AlwaysEnableStripeAutomaticTax).Returns(true);
|
|
|
|
var parsedEvent = new Event { Id = "evt_123" };
|
|
var invoice = new Invoice { CustomerId = "cus_123", AmountDue = 0, Lines = new StripeList<InvoiceLineItem> { Data = [] } };
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
CustomerId = "cus_123",
|
|
Items = new StripeList<SubscriptionItem>(),
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = "cus_123" },
|
|
Metadata = new Dictionary<string, string>()
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = "cus_123",
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "DE" },
|
|
TaxExempt = TaxExempt.None
|
|
};
|
|
var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" };
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(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);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
await _stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
|
Arg.Any<string>(), Arg.Is<CustomerUpdateOptions>(o => o.TaxExempt != null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndFeatureFlagOff_FallsThroughToStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(false);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _assignmentRepository.DidNotReceiveWithAnyArgs().GetByOrganizationIdAsync(Arg.Any<Guid>());
|
|
await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs()
|
|
.ScheduleBusinessPriceIncrease(default!, default!);
|
|
await _mailService.Received(1).SendInvoiceUpcoming(
|
|
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<DateTime>(),
|
|
Arg.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndNoCohortAssignment_FallsThroughToStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
var enterprisePlan = new EnterprisePlan(isAnnual: true);
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually).Returns(enterprisePlan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId)
|
|
.Returns((OrganizationPlanMigrationCohortAssignment?)null);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs()
|
|
.ScheduleBusinessPriceIncrease(default!, default!);
|
|
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.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndAssignmentAlreadyScheduled_FallsThroughToStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = Guid.NewGuid(),
|
|
ScheduledDate = DateTime.UtcNow.AddDays(-1)
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.DidNotReceiveWithAnyArgs()
|
|
.ScheduleBusinessPriceIncrease(default!, default!);
|
|
await _cohortRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
|
|
await _mailService.Received(1).SendInvoiceUpcoming(
|
|
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<DateTime>(),
|
|
Arg.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndCohortInactive_FallsThroughToStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "enterprise-2020-annual-paused",
|
|
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
|
|
IsActive = false
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1)
|
|
.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>());
|
|
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.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndCohortHasNoMigrationPath_FallsThroughSilently()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "churn-only-cohort",
|
|
MigrationPathId = null,
|
|
IsActive = true
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1)
|
|
.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>());
|
|
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.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndUnknownMigrationPathId_LogsErrorAndFallsThrough()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "data-corruption-cohort",
|
|
MigrationPathId = (MigrationPathId)99,
|
|
IsActive = true
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1)
|
|
.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>());
|
|
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.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndOrgPlanDriftedFromCohortSource_LogsAndFallsThrough()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually
|
|
};
|
|
var enterprisePlan = new EnterprisePlan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "enterprise-2020-annual",
|
|
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
|
|
IsActive = true
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually).Returns(enterprisePlan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1)
|
|
.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>());
|
|
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.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndSchedulerReturnsTrue_InvokesPlaceholderEmail_AndSuppressesStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var enterprisePlan = new EnterprisePlan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "enterprise-2020-annual",
|
|
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
|
|
IsActive = true
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually).Returns(enterprisePlan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
_priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>())
|
|
.Returns(true);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _priceIncreaseScheduler.Received(1)
|
|
.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>());
|
|
_logger.Received(1).Log(
|
|
LogLevel.Information,
|
|
Arg.Any<EventId>(),
|
|
Arg.Is<object>(o =>
|
|
o.ToString()!.Contains("Business renewal email is not yet wired up") &&
|
|
o.ToString()!.Contains(_organizationId.ToString()) &&
|
|
o.ToString()!.Contains(cohort.Name) &&
|
|
o.ToString()!.Contains(nameof(PlanType.EnterpriseAnnually2020)) &&
|
|
o.ToString()!.Contains(nameof(PlanType.EnterpriseAnnually))),
|
|
Arg.Any<Exception?>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
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_WhenBusinessTier_AndSchedulerReturnsFalse_FallsThroughToStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "enterprise-2020-annual",
|
|
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
|
|
IsActive = true
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
|
|
// Act — scheduler returns false (NSubstitute default for Task<bool>)
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
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.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
_logger.DidNotReceive().Log(
|
|
LogLevel.Information,
|
|
Arg.Any<EventId>(),
|
|
Arg.Is<object>(o => o.ToString()!.Contains("Business renewal email is not yet wired up")),
|
|
Arg.Any<Exception?>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
await _pricingClient.DidNotReceive().GetPlanOrThrow(PlanType.EnterpriseAnnually);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenBusinessTier_AndSchedulerThrows_LogsErrorAndFallsThroughToStandardEmail()
|
|
{
|
|
// Arrange
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration).Returns(true);
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.EnterpriseAnnually2020);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.EnterpriseAnnually2020
|
|
};
|
|
var enterprise2020Plan = new Enterprise2020Plan(isAnnual: true);
|
|
var cohortId = Guid.NewGuid();
|
|
var assignment = new OrganizationPlanMigrationCohortAssignment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OrganizationId = _organizationId,
|
|
CohortId = cohortId,
|
|
ScheduledDate = null
|
|
};
|
|
var cohort = new OrganizationPlanMigrationCohort
|
|
{
|
|
Id = cohortId,
|
|
Name = "enterprise-2020-annual",
|
|
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
|
|
IsActive = true
|
|
};
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.EnterpriseAnnually2020).Returns(enterprise2020Plan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
_assignmentRepository.GetByOrganizationIdAsync(_organizationId).Returns(assignment);
|
|
_cohortRepository.GetByIdAsync(cohortId).Returns(cohort);
|
|
_priceIncreaseScheduler.ScheduleForSubscription(subscription, Arg.Any<OrganizationPriceIncreaseOptions>())
|
|
.ThrowsAsync(new Exception("boom"));
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
_logger.Received(1).Log(
|
|
LogLevel.Error,
|
|
Arg.Any<EventId>(),
|
|
Arg.Is<object>(o =>
|
|
o.ToString()!.Contains("Failed to schedule business price migration for Organization") &&
|
|
o.ToString()!.Contains(_organizationId.ToString()) &&
|
|
o.ToString()!.Contains(parsedEvent.Id)),
|
|
Arg.Any<Exception>(),
|
|
Arg.Any<Func<object, Exception?, string>>());
|
|
await _mailService.Received(1).SendInvoiceUpcoming(
|
|
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<DateTime>(),
|
|
Arg.Any<List<string>>(),
|
|
Arg.Is<bool>(b => b));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAsync_WhenFreeTier_DispatcherReturnsFalse_AndStandardEmailSent()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.Free);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.Free
|
|
};
|
|
var freePlan = new FreePlan();
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.Free).Returns(freePlan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert — dispatcher never enters the business branch.
|
|
await _assignmentRepository.DidNotReceiveWithAnyArgs()
|
|
.GetByOrganizationIdAsync(Arg.Any<Guid>());
|
|
// FreePlan.IsAnnual == false, so the upcoming-invoice email path short-circuits — verifying
|
|
// existing behavior is preserved.
|
|
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_WhenTeamsStarter_DispatcherReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
|
var (invoice, subscription, customer) = BuildBusinessFixture(PlanType.TeamsStarter2023);
|
|
var organization = new Organization
|
|
{
|
|
Id = _organizationId,
|
|
BillingEmail = "org@example.com",
|
|
PlanType = PlanType.TeamsStarter2023
|
|
};
|
|
var teamsStarterPlan = new TeamsStarterPlan2023();
|
|
|
|
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
|
_stripeAdapter.GetCustomerAsync(customer.Id, 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.TeamsStarter2023).Returns(teamsStarterPlan);
|
|
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
|
|
|
// Act
|
|
await _sut.HandleAsync(parsedEvent);
|
|
|
|
// Assert
|
|
await _assignmentRepository.DidNotReceiveWithAnyArgs()
|
|
.GetByOrganizationIdAsync(Arg.Any<Guid>());
|
|
}
|
|
|
|
private (Invoice invoice, Subscription subscription, Customer customer) BuildBusinessFixture(
|
|
PlanType planType)
|
|
{
|
|
var customerId = $"cus_{planType}";
|
|
var subscriptionId = $"sub_{planType}";
|
|
var invoice = new Invoice
|
|
{
|
|
CustomerId = customerId,
|
|
AmountDue = 60000,
|
|
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
|
Lines = new StripeList<InvoiceLineItem>
|
|
{
|
|
Data = [new() { Description = "Test Item" }]
|
|
}
|
|
};
|
|
var subscription = new Subscription
|
|
{
|
|
Id = subscriptionId,
|
|
CustomerId = customerId,
|
|
Items = new StripeList<SubscriptionItem>
|
|
{
|
|
Data = [new SubscriptionItem { Price = new Price { Id = "stub-price" } }]
|
|
},
|
|
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
|
Customer = new Customer { Id = customerId },
|
|
Metadata = new Dictionary<string, string>(),
|
|
LatestInvoiceId = "inv_latest"
|
|
};
|
|
var customer = new Customer
|
|
{
|
|
Id = customerId,
|
|
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
|
|
Address = new Address { Country = "US" }
|
|
};
|
|
return (invoice, subscription, customer);
|
|
}
|
|
|
|
}
|
|
|