diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 3ebc2b3781..f8c60cd31a 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; +using Exceptionless.DateTimeExtensions; using Foundatio.Lock; namespace Exceptionless.Core.Billing; @@ -106,16 +107,23 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + CaptureOutgoingUsageLimit(organization, plan, utcNow); + organization.PlanId = plan.Id; organization.PlanName = plan.Name; organization.PlanDescription = plan.Description; - organization.BillingChangeDate = _timeProvider.GetUtcNow().UtcDateTime; + organization.BillingChangeDate = utcNow; if (updateBillingPrice) + { organization.BillingPrice = plan.Price; + } if (user is not null) + { organization.BillingChangedByUserId = user.Id; + } organization.MaxUsers = plan.MaxUsers; organization.MaxProjects = plan.MaxProjects; @@ -126,6 +134,29 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? organization.GetCurrentUsage(_timeProvider).Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider); } + private void CaptureOutgoingUsageLimit(Organization organization, BillingPlan plan, DateTime utcNow) + { + if (String.IsNullOrEmpty(organization.PlanId) + || String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) + || organization.MaxEventsPerMonth == 0) + { + return; + } + + var previousMonthUtc = utcNow.StartOfMonth().AddMonths(-1); + var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); + if (previousMonthUtc < organizationCreatedMonthUtc) + { + return; + } + + var previousUsage = organization.Usage.GetOrAddMonthlyUsage(previousMonthUtc, organization.MaxEventsPerMonth); + if (previousUsage.Limit == 0) + { + previousUsage.Limit = organization.MaxEventsPerMonth; + } + } + public void ApplyBonus(Organization organization, int bonusEvents, DateTime? expires = null) { organization.BonusEventsPerMonth = bonusEvents; diff --git a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs index f296861efa..bc8c37b591 100644 --- a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs +++ b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs @@ -129,18 +129,6 @@ public static UsageInfo GetCurrentUsage(this Organization organization, TimeProv public static UsageInfo GetUsage(this Organization organization, DateTime date, TimeProvider timeProvider) { - var startOfMonth = date.ToUniversalTime().StartOfMonth(); - var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month); - if (usage is not null) - return usage; - - usage = new UsageInfo - { - Date = startOfMonth, - Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider) - }; - organization.Usage.Add(usage); - - return usage; + return organization.Usage.GetOrAddMonthlyUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); } } diff --git a/src/Exceptionless.Core/Extensions/ProjectExtensions.cs b/src/Exceptionless.Core/Extensions/ProjectExtensions.cs index de6375f48e..41a1d05ec7 100644 --- a/src/Exceptionless.Core/Extensions/ProjectExtensions.cs +++ b/src/Exceptionless.Core/Extensions/ProjectExtensions.cs @@ -125,16 +125,6 @@ public static UsageInfo GetCurrentUsage(this Project project, TimeProvider timeP public static UsageInfo GetUsage(this Project project, DateTime date) { - var usage = project.Usage.FirstOrDefault(o => o.Date == date.ToUniversalTime().StartOfMonth()); - if (usage is not null) - return usage; - - usage = new UsageInfo - { - Date = date.ToUniversalTime().StartOfMonth(), - }; - project.Usage.Add(usage); - - return usage; + return project.Usage.GetOrAddMonthlyUsage(date, 0); } } diff --git a/src/Exceptionless.Core/Extensions/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index c7cb862958..84f070c5d2 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -1,9 +1,60 @@ using Exceptionless.Core.Models; +using Exceptionless.DateTimeExtensions; namespace Exceptionless.Core.Extensions; public static class UsageExtensions { + public static UsageInfo GetOrAddMonthlyUsage(this ICollection usages, DateTime dateUtc, int limit) + { + var startOfMonth = dateUtc.ToUniversalTime().StartOfMonth(); + var usage = usages.FirstOrDefault(u => u.Date.Year == startOfMonth.Year && u.Date.Month == startOfMonth.Month); + if (usage is not null) + { + return usage; + } + + usage = new UsageInfo + { + Date = startOfMonth, + Limit = limit + }; + usages.Add(usage); + + return usage; + } + + public static ICollection MaterializeMonthlyUsage(this IEnumerable usages, DateTime startDateUtc, DateTime endDateUtc, int fallbackLimit) + { + var materialized = usages.Select(usage => usage with { }).ToList(); + var startOfMonthUtc = startDateUtc.ToUniversalTime().StartOfMonth(); + var endOfMonthUtc = endDateUtc.ToUniversalTime().StartOfMonth(); + if (startOfMonthUtc > endOfMonthUtc) + return materialized; + + var knownUsages = materialized + .Where(usage => usage.Limit != 0) + .OrderBy(usage => usage.Date) + .ToList(); + int limit = knownUsages + .LastOrDefault(usage => usage.Date <= startOfMonthUtc)?.Limit + ?? knownUsages.FirstOrDefault()?.Limit + ?? fallbackLimit; + + while (startOfMonthUtc <= endOfMonthUtc) + { + var usage = materialized.GetOrAddMonthlyUsage(startOfMonthUtc, limit); + if (usage.Limit == 0) + usage.Limit = limit; + else + limit = usage.Limit; + + startOfMonthUtc = startOfMonthUtc.AddMonths(1); + } + + return materialized.OrderBy(usage => usage.Date).ToList(); + } + public static void SetUsage(this ICollection usages, DateTime dateUtc, int total, int blocked, int tooBig, int limit, TimeSpan? maxUsageAge, TimeProvider timeProvider) { var usageInfo = usages.FirstOrDefault(o => o.Date == dateUtc); diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index dbc5ed247f..901c28144b 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -513,7 +513,7 @@ private async Task AfterResultMapAsync(ICollection m viewProject.HasPremiumFeatures = organization.HasPremiumFeatures; var realTimeUsage = await usageService.GetUsageAsync(organization.Id, viewProject.Id); - viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); + viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), organization.CreatedUtc, timeProvider); viewProject.TrimUsage(timeProvider); var currentUsage = viewProject.GetCurrentUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 90bdd3fffd..9f60cf30eb 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Attributes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.DateTimeExtensions; using Foundatio.Repositories.Models; @@ -73,13 +74,15 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider) { - var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractMonths(11).StartOfMonth(); - - while (startDate <= timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) - { - organization.GetUsage(startDate, timeProvider); - startDate = startDate.AddMonths(1).StartOfMonth(); - } + var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); + var startDateUtc = endDateUtc.SubtractMonths(11); + var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); + startDateUtc = organizationCreatedMonthUtc > startDateUtc ? organizationCreatedMonthUtc : startDateUtc; + + organization.Usage = organization.Usage.MaterializeMonthlyUsage( + startDateUtc, + endDateUtc, + organization.GetMaxEventsPerMonthWithBonus(timeProvider)); } public static UsageInfo GetCurrentUsage(this ViewOrganization organization, TimeProvider timeProvider) @@ -89,19 +92,7 @@ public static UsageInfo GetCurrentUsage(this ViewOrganization organization, Time public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, TimeProvider timeProvider) { - var startOfMonth = date.ToUniversalTime().StartOfMonth(); - var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month); - if (usage is not null) - return usage; - - usage = new UsageInfo - { - Date = startOfMonth, - Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider) - }; - organization.Usage.Add(usage); - - return usage; + return organization.Usage.GetOrAddMonthlyUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); } public static int GetMaxEventsPerMonthWithBonus(this ViewOrganization organization, TimeProvider timeProvider) diff --git a/src/Exceptionless.Web/Models/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index 52f5576e9a..acd15da925 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Attributes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.DateTimeExtensions; using Foundatio.Repositories.Models; @@ -49,15 +50,16 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewProject project, Time return project.GetHourlyUsage(timeProvider.GetUtcNow().UtcDateTime); } - public static void EnsureUsage(this ViewProject project, int limit, TimeProvider timeProvider) + public static void EnsureUsage(this ViewProject project, int limit, DateTime organizationCreatedUtc, TimeProvider timeProvider) { - var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractYears(1).StartOfMonth(); + var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); + var startDateUtc = endDateUtc.SubtractYears(1); + var organizationCreatedMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth(); + var projectCreatedMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth(); + var createdMonthUtc = projectCreatedMonthUtc > organizationCreatedMonthUtc ? projectCreatedMonthUtc : organizationCreatedMonthUtc; + startDateUtc = createdMonthUtc > startDateUtc ? createdMonthUtc : startDateUtc; - while (startDate < timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) - { - project.GetUsage(startDate, limit); - startDate = startDate.AddMonths(1).StartOfMonth(); - } + project.Usage = project.Usage.MaterializeMonthlyUsage(startDateUtc, endDateUtc.AddMonths(-1), limit); } public static UsageInfo GetCurrentUsage(this ViewProject project, int limit, TimeProvider timeProvider) @@ -67,18 +69,7 @@ public static UsageInfo GetCurrentUsage(this ViewProject project, int limit, Tim public static UsageInfo GetUsage(this ViewProject project, DateTime date, int limit) { - var usage = project.Usage.FirstOrDefault(o => o.Date == date.ToUniversalTime().StartOfMonth()); - if (usage is not null) - return usage; - - usage = new UsageInfo - { - Date = date.ToUniversalTime().StartOfMonth(), - Limit = limit - }; - project.Usage.Add(usage); - - return usage; + return project.Usage.GetOrAddMonthlyUsage(date, limit); } public static void TrimUsage(this ViewProject project, TimeProvider timeProvider) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index f46babddc4..9a6f35bb28 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -1473,7 +1473,7 @@ public async Task ShouldRespectEventUsageLimits() Assert.NotNull(viewOrganization); Assert.False(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - Assert.Equal(12, viewOrganization.Usage.Count); + Assert.Single(viewOrganization.Usage); Assert.Single(viewOrganization.UsageHours); // submit bach of events one over limit @@ -1497,7 +1497,7 @@ await SendRequestAsync(r => r Assert.NotNull(viewOrganization); Assert.False(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - Assert.Equal(12, viewOrganization.Usage.Count); + Assert.Single(viewOrganization.Usage); Assert.Single(viewOrganization.UsageHours); // process events @@ -1596,7 +1596,7 @@ await SendRequestAsync(r => r organization = await _organizationRepository.GetByIdAsync(organizationId); Assert.NotNull(organization); - organizationUsage = organization.Usage.Single(); + organizationUsage = organization.GetCurrentUsage(TimeProvider); Assert.Equal(total, organizationUsage.Total); Assert.Equal(blocked, organizationUsage.Blocked); Assert.Equal(0, organizationUsage.TooBig); @@ -1725,7 +1725,7 @@ public async Task PlanChangeShouldAllowEventSubmission() Assert.NotNull(viewOrganization); Assert.False(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - Assert.Equal(12, viewOrganization.Usage.Count); + Assert.Single(viewOrganization.Usage); Assert.Single(viewOrganization.UsageHours); // submit bach of events one over limit @@ -1857,7 +1857,7 @@ await SendRequestAsync(r => r organization = await _organizationRepository.GetByIdAsync(organizationId); Assert.NotNull(organization); - organizationUsage = organization.Usage.Single(); + organizationUsage = organization.GetCurrentUsage(TimeProvider); Assert.Equal(total, organizationUsage.Total); Assert.Equal(blocked, organizationUsage.Blocked); Assert.Equal(0, organizationUsage.TooBig); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index e7227cd0ca..233db43eb1 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -422,6 +422,47 @@ public async Task GetAsync_ExistingOrganization_MapsToViewOrganization() Assert.NotNull(viewOrg.PlanName); } + [Fact] + public async Task GetAsync_WithSparsePlanHistory_BackfillsFromKnownLimits() + { + // Arrange + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(organization); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(user); + + var currentPlanDate = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(currentPlanDate); + _billingManager.ApplyBillingPlan(organization, _plans.LargePlan, user); + organization.CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc); + organization.Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = _plans.MediumPlan.MaxEventsPerMonth }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = _plans.LargePlan.MaxEventsPerMonth } + ]; + organization.StripeCustomerId = "cus_usage_history"; + organization.CardLast4 = "4242"; + organization.SubscribeDate = currentPlanDate; + organization.BillingStatus = BillingStatus.Active; + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); + + // Act + var viewOrganization = await SendRequestAsAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewOrganization); + Assert.DoesNotContain(viewOrganization.Usage, usage => usage.Date < new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.LargePlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public async Task GetAsync_WithStatsMode_ReturnsPopulatedViewOrganization() { diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 3618c5200d..bc412f7faa 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -1,13 +1,117 @@ using Exceptionless.Core.Billing; using Exceptionless.Core.Models; +using Exceptionless.DateTimeExtensions; using Xunit; namespace Exceptionless.Tests.Billing; public class BillingManagerTests : TestWithServices { + private static readonly DateTime PlanChangeUtc = new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime PreviousMonthUtc = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + public BillingManagerTests(ITestOutputHelper output) : base(output) { } + [Theory] + [InlineData("bonus without usage", null, 5_000, 15_000)] + [InlineData("persisted bonus", 20_000, 5_000, 20_000)] + [InlineData("missing usage", null, 0, 15_000)] + [InlineData("zero-valued usage", 0, 0, 15_000)] + public void ApplyBillingPlan_ConfirmedPreviousPlan_CreatesExpectedAnchor(string _, int? persistedLimit, int bonusEvents, int expectedLimit) + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(PlanChangeUtc); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = bonusEvents, + BonusExpiration = bonusEvents > 0 ? PlanChangeUtc.AddMonths(1) : null + }; + if (persistedLimit.HasValue) + organization.Usage = [new UsageInfo { Date = PreviousMonthUtc, Limit = persistedLimit.Value }]; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(2, organization.Usage.Count); + Assert.DoesNotContain(organization.Usage, usage => usage.Date < new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.Equal(expectedLimit, organization.Usage.Single(usage => usage.Date == PreviousMonthUtc).Limit); + Assert.Equal(plans.MediumPlan.MaxEventsPerMonth + bonusEvents, + organization.Usage.Single(usage => usage.Date == PlanChangeUtc.StartOfMonth()).Limit); + } + + [Theory] + [InlineData(PreviousPlanScenario.MissingLimit)] + [InlineData(PreviousPlanScenario.NewOrganization)] + [InlineData(PreviousPlanScenario.CreatedThisMonth)] + [InlineData(PreviousPlanScenario.UnchangedPlan)] + public void ApplyBillingPlan_WithoutConfirmedPreviousMonth_DoesNotCreateAnchor(PreviousPlanScenario scenario) + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(PlanChangeUtc); + var (organization, targetPlan) = scenario switch + { + PreviousPlanScenario.MissingLimit => ( + new Organization { CreatedUtc = PlanChangeUtc.AddMonths(-2), PlanId = plans.SmallPlan.Id }, + plans.MediumPlan), + PreviousPlanScenario.NewOrganization => (new Organization(), plans.FreePlan), + PreviousPlanScenario.CreatedThisMonth => ( + new Organization + { + CreatedUtc = PlanChangeUtc.AddDays(-5), + PlanId = plans.FreePlan.Id, + MaxEventsPerMonth = plans.FreePlan.MaxEventsPerMonth + }, + plans.SmallPlan), + PreviousPlanScenario.UnchangedPlan => ( + new Organization + { + CreatedUtc = PlanChangeUtc.AddMonths(-2), + PlanId = plans.SmallPlan.Id.ToUpperInvariant(), + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + }, + plans.SmallPlan), + _ => throw new ArgumentOutOfRangeException(nameof(scenario)) + }; + + // Act + billingManager.ApplyBillingPlan(organization, targetPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(PlanChangeUtc.StartOfMonth(), usage.Date); + Assert.Equal(targetPlan.MaxEventsPerMonth, usage.Limit); + } + + [Fact] + public void ApplyBillingPlan_UnchangedPlan_UpdatesBillingChangeDate() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BillingChangeDate = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + Assert.Equal(TimeProvider.GetUtcNow().UtcDateTime, organization.BillingChangeDate); + } + [Fact] public void GetBillingPlan() { @@ -97,4 +201,12 @@ public void SetStripeSubscriptionId_UnchangedOwner_PreservesEventWatermark() // Assert Assert.Equal(eventWatermarkUtc, organization.StripeSubscriptionEventDate); } + + public enum PreviousPlanScenario + { + MissingLimit, + NewOrganization, + CreatedThisMonth, + UnchangedPlan + } } diff --git a/tests/Exceptionless.Tests/Extensions/UsageExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/UsageExtensionsTests.cs new file mode 100644 index 0000000000..27aa40d74d --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/UsageExtensionsTests.cs @@ -0,0 +1,31 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public class UsageExtensionsTests +{ + [Fact] + public void MaterializeMonthlyUsage_ReturnsNewHistoryWithoutMutatingSource() + { + // Arrange + var marchUsage = new UsageInfo { Date = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }; + var mayUsage = new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 }; + ICollection source = [marchUsage, mayUsage]; + + // Act + var result = source.MaterializeMonthlyUsage( + new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + 75_000); + + // Assert + Assert.Equal(2, source.Count); + Assert.Same(marchUsage, source.Single(usage => usage.Date.Month == 3)); + Assert.NotSame(marchUsage, result.Single(usage => usage.Date.Month == 3)); + Assert.Equal(3_000, result.Single(usage => usage.Date.Month == 2).Limit); + Assert.Equal(3_000, result.Single(usage => usage.Date.Month == 4).Limit); + Assert.Equal(15_000, result.Single(usage => usage.Date.Month == 5).Limit); + } +} diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs new file mode 100644 index 0000000000..0d6ab350df --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -0,0 +1,145 @@ +using Exceptionless.Core.Models; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public class ViewOrganizationExtensionsTests +{ + private readonly ProxyTimeProvider _timeProvider = new(); + + [Fact] + public void EnsureUsage_ActiveBonus_AppliesBonusWhenHistoryIsEmpty() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3, organization.Usage.Count); + Assert.All(organization.Usage, usage => Assert.Equal(20_000, usage.Limit)); + } + + [Fact] + public void EnsureUsage_ExistingUsage_PrefillsFromOldestKnownLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_NonzeroUsage_PreservesPersistedLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + BonusEventsPerMonth = 60_000, + BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = [new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_SparseHistory_CarriesKnownLimitsBetweenChanges() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 75_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_UnlimitedPlan_CarriesUnlimitedLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = -1 + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(2, organization.Usage.Count); + Assert.All(organization.Usage, usage => Assert.Equal(-1, usage.Limit)); + } + + [Fact] + public void EnsureUsage_UnsetLimit_ReplacesItWithKnownLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } +} diff --git a/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs new file mode 100644 index 0000000000..f3067df105 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs @@ -0,0 +1,54 @@ +using Exceptionless.Core.Models; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public class ViewProjectExtensionsTests +{ + private readonly ProxyTimeProvider _timeProvider = new(); + + [Fact] + public void EnsureUsage_SparseHistory_CarriesKnownLimitsBetweenChanges() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organizationCreatedUtc = new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc); + var project = new ViewProject + { + CreatedUtc = organizationCreatedUtc, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 } + ] + }; + + // Act + project.EnsureUsage(15_000, organizationCreatedUtc, _timeProvider); + + // Assert + Assert.Equal(3_000, project.Usage.Single(u => u.Date == new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(3_000, project.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_YoungOrganization_DoesNotCreateUsageBeforeProject() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organizationCreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc); + var project = new ViewProject + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + project.EnsureUsage(15_000, organizationCreatedUtc, _timeProvider); + + // Assert + Assert.Equal(2, project.Usage.Count); + Assert.DoesNotContain(project.Usage, usage => usage.Date < new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + } +}