From 2a4259dc8168bc8c1eb38093e0264692ed556e9e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 27 Jul 2026 22:18:33 -0500 Subject: [PATCH 01/26] Fix historical usage limit backfill --- .../Api/Handlers/OrganizationHandler.cs | 2 +- .../Models/Organization/ViewOrganization.cs | 15 ++++++-- .../Endpoints/OrganizationEndpointTests.cs | 38 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index b2984736f1..69cc3c41b8 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -922,7 +922,7 @@ private async Task AfterResultMapAsync(ICollection m viewOrganization.IconUrl = GetOrganizationIconUrl(viewOrganization.Id, viewOrganization.IconUrl); var realTimeUsage = await usageService.GetUsageAsync(viewOrganization.Id); - viewOrganization.EnsureUsage(timeProvider); + viewOrganization.EnsureUsage(plans.FreePlan.MaxEventsPerMonth, timeProvider); viewOrganization.TrimUsage(timeProvider); var currentUsage = viewOrganization.GetCurrentUsage(timeProvider); diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 5c46965b96..1e3534c16b 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -73,13 +73,17 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza return organization.GetHourlyUsage(timeProvider.GetUtcNow().UtcDateTime); } - public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider) + public static void EnsureUsage(this ViewOrganization organization, int freePlanLimit, TimeProvider timeProvider) { var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractMonths(11).StartOfMonth(); + var subscriptionStartDate = organization.SubscribeDate?.ToUniversalTime().StartOfMonth(); while (startDate <= timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) { - organization.GetUsage(startDate, timeProvider); + int limit = subscriptionStartDate.HasValue && startDate < subscriptionStartDate.Value + ? freePlanLimit + : organization.GetMaxEventsPerMonthWithBonus(timeProvider); + organization.GetUsage(startDate, limit); startDate = startDate.AddMonths(1).StartOfMonth(); } } @@ -90,6 +94,11 @@ public static UsageInfo GetCurrentUsage(this ViewOrganization organization, Time } public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, TimeProvider timeProvider) + { + return organization.GetUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); + } + + public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, int limit) { var startOfMonth = date.ToUniversalTime().StartOfMonth(); var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month); @@ -99,7 +108,7 @@ public static UsageInfo GetUsage(this ViewOrganization organization, DateTime da usage = new UsageInfo { Date = startOfMonth, - Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider) + Limit = limit }; organization.Usage.Add(usage); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 13d0c6fbb5..8614a1235e 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -459,6 +459,44 @@ public async Task GetAsync_WithStatsMode_ReturnsPopulatedViewOrganization() Assert.NotNull(organizationView.UsageHours); } + [Fact] + public async Task GetAsync_AfterFreePlanUpgrade_BackfillsMissingUsageWithFreePlanLimit() + { + // 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 freePlanDate = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(freePlanDate); + organization.Usage.Clear(); + _billingManager.ApplyBillingPlan(organization, _plans.FreePlan, user); + + var paidPlanDate = freePlanDate.AddMonths(1); + TimeProvider.SetUtcNow(paidPlanDate); + organization.SubscribeDate = paidPlanDate; + _billingManager.ApplyBillingPlan(organization, _plans.SmallYearlyPlan, user); + organization.StripeCustomerId = "cus_usage_history"; + organization.CardLast4 = "4242"; + 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.Equal(_plans.FreePlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.FreePlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.SmallYearlyPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public async Task UploadIconAsync_ImageOverGlobalRequestLimit_ReturnsUpdatedOrganization() { From 732e20df03e829e8045582d5ca8c826f33b693ac Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 28 Jul 2026 21:43:40 -0500 Subject: [PATCH 02/26] Derive usage backfill from known limits --- .../Billing/BillingManager.cs | 3 + .../Extensions/UsageExtensions.cs | 32 ++++++++++ .../Api/Handlers/OrganizationHandler.cs | 2 +- .../Models/Organization/ViewOrganization.cs | 22 ++----- .../Endpoints/OrganizationEndpointTests.cs | 63 ++++++++++--------- .../Billing/BillingManagerTests.cs | 37 +++++++++++ 6 files changed, 109 insertions(+), 50 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 3ebc2b3781..714244128a 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -106,6 +106,9 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { + if (!String.IsNullOrEmpty(organization.PlanId)) + organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), _timeProvider); + organization.PlanId = plan.Id; organization.PlanName = plan.Name; organization.PlanDescription = plan.Description; diff --git a/src/Exceptionless.Core/Extensions/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index c7cb862958..7b787c3c6b 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -1,9 +1,41 @@ using Exceptionless.Core.Models; +using Exceptionless.DateTimeExtensions; namespace Exceptionless.Core.Extensions; public static class UsageExtensions { + public static void EnsureUsage(this ICollection usages, int defaultLimit, TimeProvider timeProvider) + { + var endDate = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); + var startDate = endDate.SubtractMonths(11); + int limit = usages + .Where(u => u.Date <= startDate) + .OrderByDescending(u => u.Date) + .FirstOrDefault()?.Limit + ?? usages.OrderBy(u => u.Date).FirstOrDefault()?.Limit + ?? defaultLimit; + + while (startDate <= endDate) + { + var usage = usages.FirstOrDefault(u => u.Date.Year == startDate.Year && u.Date.Month == startDate.Month); + if (usage is not null) + { + limit = usage.Limit; + } + else + { + usages.Add(new UsageInfo + { + Date = startDate, + Limit = limit + }); + } + + startDate = startDate.AddMonths(1).StartOfMonth(); + } + } + 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/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 69cc3c41b8..b2984736f1 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -922,7 +922,7 @@ private async Task AfterResultMapAsync(ICollection m viewOrganization.IconUrl = GetOrganizationIconUrl(viewOrganization.Id, viewOrganization.IconUrl); var realTimeUsage = await usageService.GetUsageAsync(viewOrganization.Id); - viewOrganization.EnsureUsage(plans.FreePlan.MaxEventsPerMonth, timeProvider); + viewOrganization.EnsureUsage(timeProvider); viewOrganization.TrimUsage(timeProvider); var currentUsage = viewOrganization.GetCurrentUsage(timeProvider); diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 1e3534c16b..8edd061fa4 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,19 +74,9 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza return organization.GetHourlyUsage(timeProvider.GetUtcNow().UtcDateTime); } - public static void EnsureUsage(this ViewOrganization organization, int freePlanLimit, TimeProvider timeProvider) + public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider) { - var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractMonths(11).StartOfMonth(); - var subscriptionStartDate = organization.SubscribeDate?.ToUniversalTime().StartOfMonth(); - - while (startDate <= timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) - { - int limit = subscriptionStartDate.HasValue && startDate < subscriptionStartDate.Value - ? freePlanLimit - : organization.GetMaxEventsPerMonthWithBonus(timeProvider); - organization.GetUsage(startDate, limit); - startDate = startDate.AddMonths(1).StartOfMonth(); - } + organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); } public static UsageInfo GetCurrentUsage(this ViewOrganization organization, TimeProvider timeProvider) @@ -94,11 +85,6 @@ public static UsageInfo GetCurrentUsage(this ViewOrganization organization, Time } public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, TimeProvider timeProvider) - { - return organization.GetUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); - } - - public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, int limit) { var startOfMonth = date.ToUniversalTime().StartOfMonth(); var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month); @@ -108,7 +94,7 @@ public static UsageInfo GetUsage(this ViewOrganization organization, DateTime da usage = new UsageInfo { Date = startOfMonth, - Limit = limit + Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider) }; organization.Usage.Add(usage); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 8614a1235e..9f01d70094 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -442,25 +442,7 @@ public async Task GetAsync_ExistingOrganization_MapsToViewOrganization() } [Fact] - public async Task GetAsync_WithStatsMode_ReturnsPopulatedViewOrganization() - { - // Act - var organizationView = await SendRequestAsAsync(r => r - .AsTestOrganizationUser() - .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) - .QueryString("mode", "stats") - .StatusCodeShouldBeOk() - ); - - // Assert - Assert.NotNull(organizationView); - Assert.Equal(SampleDataService.TEST_ORG_ID, organizationView.Id); - Assert.NotNull(organizationView.Usage); - Assert.NotNull(organizationView.UsageHours); - } - - [Fact] - public async Task GetAsync_AfterFreePlanUpgrade_BackfillsMissingUsageWithFreePlanLimit() + public async Task GetAsync_WithSparsePlanHistory_BackfillsFromKnownLimits() { // Arrange var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); @@ -469,17 +451,17 @@ public async Task GetAsync_AfterFreePlanUpgrade_BackfillsMissingUsageWithFreePla var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); Assert.NotNull(user); - var freePlanDate = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc); - TimeProvider.SetUtcNow(freePlanDate); - organization.Usage.Clear(); - _billingManager.ApplyBillingPlan(organization, _plans.FreePlan, user); - - var paidPlanDate = freePlanDate.AddMonths(1); - TimeProvider.SetUtcNow(paidPlanDate); - organization.SubscribeDate = paidPlanDate; - _billingManager.ApplyBillingPlan(organization, _plans.SmallYearlyPlan, user); + var currentPlanDate = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(currentPlanDate); + _billingManager.ApplyBillingPlan(organization, _plans.LargePlan, user); + 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()); @@ -492,9 +474,28 @@ public async Task GetAsync_AfterFreePlanUpgrade_BackfillsMissingUsageWithFreePla // Assert Assert.NotNull(viewOrganization); - Assert.Equal(_plans.FreePlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(_plans.FreePlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(_plans.SmallYearlyPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + 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() + { + // Act + var organizationView = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) + .QueryString("mode", "stats") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(organizationView); + Assert.Equal(SampleDataService.TEST_ORG_ID, organizationView.Id); + Assert.NotNull(organizationView.Usage); + Assert.NotNull(organizationView.UsageHours); } [Fact] diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 3618c5200d..01c7fa4f54 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -8,6 +8,43 @@ public class BillingManagerTests : TestWithServices { public BillingManagerTests(ITestOutputHelper output) : base(output) { } + [Fact] + public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit() + { + var billingManager = GetService(); + var plans = GetService(); + var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(utcNow); + var organization = new Organization + { + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + }; + + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + Assert.Equal(12, organization.Usage.Count); + Assert.All(organization.Usage.Where(u => u.Date < new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + usage => Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit)); + Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void ApplyBillingPlan_NewOrganization_DoesNotInventPreviousPlanHistory() + { + var billingManager = GetService(); + var plans = GetService(); + var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(utcNow); + var organization = new Organization(); + + billingManager.ApplyBillingPlan(organization, plans.FreePlan); + + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.FreePlan.MaxEventsPerMonth, usage.Limit); + } + [Fact] public void GetBillingPlan() { From 76bcf538843a14e84ac4d478a2086c048c632391 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 28 Jul 2026 22:00:59 -0500 Subject: [PATCH 03/26] Update plan change usage assertions --- tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index f46babddc4..06b68382e4 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -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); @@ -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); From ab5fb11c42047013f9a5c4e0748e7d95026ca2a4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 28 Jul 2026 22:13:28 -0500 Subject: [PATCH 04/26] Consolidate usage bucket creation --- .../Extensions/OrganizationExtensions.cs | 14 +------- .../Extensions/UsageExtensions.cs | 32 +++++++++++-------- .../Models/Organization/ViewOrganization.cs | 14 +------- .../Models/Project/ViewProject.cs | 14 ++------ .../Billing/BillingManagerTests.cs | 6 ++++ 5 files changed, 28 insertions(+), 52 deletions(-) diff --git a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs index 84b949f3fc..36953119ec 100644 --- a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs +++ b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs @@ -145,18 +145,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.GetUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); } } diff --git a/src/Exceptionless.Core/Extensions/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index 7b787c3c6b..1cf8a10020 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -18,24 +18,28 @@ public static void EnsureUsage(this ICollection usages, int defaultLi while (startDate <= endDate) { - var usage = usages.FirstOrDefault(u => u.Date.Year == startDate.Year && u.Date.Month == startDate.Month); - if (usage is not null) - { - limit = usage.Limit; - } - else - { - usages.Add(new UsageInfo - { - Date = startDate, - Limit = limit - }); - } - + limit = usages.GetUsage(startDate, limit).Limit; startDate = startDate.AddMonths(1).StartOfMonth(); } } + public static UsageInfo GetUsage(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 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/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 8edd061fa4..832415977a 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -86,19 +86,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.GetUsage(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..bf8826b03a 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; @@ -67,18 +68,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.GetUsage(date, limit); } public static void TrimUsage(this ViewProject project, TimeProvider timeProvider) diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 01c7fa4f54..6f29eaafb2 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -11,6 +11,7 @@ public BillingManagerTests(ITestOutputHelper output) : base(output) { } [Fact] public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit() { + // Arrange var billingManager = GetService(); var plans = GetService(); var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); @@ -21,8 +22,10 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth }; + // Act billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + // Assert Assert.Equal(12, organization.Usage.Count); Assert.All(organization.Usage.Where(u => u.Date < new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)), usage => Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit)); @@ -32,14 +35,17 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit [Fact] public void ApplyBillingPlan_NewOrganization_DoesNotInventPreviousPlanHistory() { + // Arrange var billingManager = GetService(); var plans = GetService(); var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); TimeProvider.SetUtcNow(utcNow); var organization = new Organization(); + // Act billingManager.ApplyBillingPlan(organization, plans.FreePlan); + // Assert var usage = Assert.Single(organization.Usage); Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); Assert.Equal(plans.FreePlan.MaxEventsPerMonth, usage.Limit); From 29b9ba26ba2856954e51140cb173e14c1fe5819f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 12:19:17 -0500 Subject: [PATCH 05/26] Bound usage history by organization creation --- src/Exceptionless.Core/Billing/BillingManager.cs | 2 +- src/Exceptionless.Core/Extensions/UsageExtensions.cs | 8 +++++++- .../Models/Organization/ViewOrganization.cs | 2 +- .../Api/Endpoints/OrganizationEndpointTests.cs | 2 ++ tests/Exceptionless.Tests/Billing/BillingManagerTests.cs | 6 ++++-- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 714244128a..28c5c59aef 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -107,7 +107,7 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { if (!String.IsNullOrEmpty(organization.PlanId)) - organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), _timeProvider); + organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), organization.CreatedUtc, _timeProvider); organization.PlanId = plan.Id; organization.PlanName = plan.Name; diff --git a/src/Exceptionless.Core/Extensions/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index 1cf8a10020..c80b51fd67 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -5,10 +5,16 @@ namespace Exceptionless.Core.Extensions; public static class UsageExtensions { - public static void EnsureUsage(this ICollection usages, int defaultLimit, TimeProvider timeProvider) + public static void EnsureUsage(this ICollection usages, int defaultLimit, DateTime earliestUsageDateUtc, TimeProvider timeProvider) { var endDate = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); var startDate = endDate.SubtractMonths(11); + var earliestUsageDate = earliestUsageDateUtc == DateTime.MinValue + ? DateTime.MinValue + : earliestUsageDateUtc.ToUniversalTime().StartOfMonth(); + if (earliestUsageDate > startDate) + startDate = earliestUsageDate; + int limit = usages .Where(u => u.Date <= startDate) .OrderByDescending(u => u.Date) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 832415977a..55fa677fc1 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -76,7 +76,7 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider) { - organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); + organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), organization.CreatedUtc, timeProvider); } public static UsageInfo GetCurrentUsage(this ViewOrganization organization, TimeProvider timeProvider) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 9f01d70094..013d872dd2 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -454,6 +454,7 @@ public async Task GetAsync_WithSparsePlanHistory_BackfillsFromKnownLimits() 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 }, @@ -474,6 +475,7 @@ public async Task GetAsync_WithSparsePlanHistory_BackfillsFromKnownLimits() // 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); diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 6f29eaafb2..ecfac809f1 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -9,7 +9,7 @@ public class BillingManagerTests : TestWithServices public BillingManagerTests(ITestOutputHelper output) : base(output) { } [Fact] - public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit() + public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsSinceOrganizationCreation() { // Arrange var billingManager = GetService(); @@ -18,6 +18,7 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit TimeProvider.SetUtcNow(utcNow); var organization = new Organization { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), PlanId = plans.SmallPlan.Id, MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth }; @@ -26,7 +27,8 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsPreviousPlanLimit billingManager.ApplyBillingPlan(organization, plans.MediumPlan); // Assert - Assert.Equal(12, organization.Usage.Count); + Assert.Equal(3, organization.Usage.Count); + Assert.DoesNotContain(organization.Usage, usage => usage.Date < new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); Assert.All(organization.Usage.Where(u => u.Date < new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)), usage => Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit)); Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); From a368f833bdf6d7dd97106cf9180ae327a3afe5e1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 12:31:31 -0500 Subject: [PATCH 06/26] Update new organization usage expectations --- tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 06b68382e4..7d91b2c3d4 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 From da721bdaa7a399597be2d4ebadcaa33d0bca4910 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 12:43:19 -0500 Subject: [PATCH 07/26] Update plan change usage count expectation --- tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 7d91b2c3d4..9a6f35bb28 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -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 From 2ac9125fea0f264157176b4ddc15e1a2e9082913 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 13:17:03 -0500 Subject: [PATCH 08/26] Harden usage history reconstruction --- .../Billing/BillingManager.cs | 16 +- .../Extensions/UsageExtensions.cs | 24 --- .../Models/Organization/ViewOrganization.cs | 57 ++++- .../Billing/BillingManagerTests.cs | 96 ++++++++- .../ViewOrganizationExtensionsTests.cs | 200 ++++++++++++++++++ 5 files changed, 363 insertions(+), 30 deletions(-) create mode 100644 tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 28c5c59aef..0bc09fb80b 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; @@ -107,7 +108,20 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { if (!String.IsNullOrEmpty(organization.PlanId)) - organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), organization.CreatedUtc, _timeProvider); + { + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + int previousLimit = organization.MaxEventsPerMonth != 0 + ? organization.MaxEventsPerMonth + : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; + if (previousLimit > 0 && organization.BonusExpiration > utcNow) + previousLimit += organization.BonusEventsPerMonth; + + var currentMonthUtc = utcNow.StartOfMonth(); + var previousMonthUtc = currentMonthUtc.AddMonths(-1); + var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); + if (previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) + organization.Usage.GetUsage(previousMonthUtc, previousLimit); + } organization.PlanId = plan.Id; organization.PlanName = plan.Name; diff --git a/src/Exceptionless.Core/Extensions/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index c80b51fd67..a030d58803 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -5,30 +5,6 @@ namespace Exceptionless.Core.Extensions; public static class UsageExtensions { - public static void EnsureUsage(this ICollection usages, int defaultLimit, DateTime earliestUsageDateUtc, TimeProvider timeProvider) - { - var endDate = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); - var startDate = endDate.SubtractMonths(11); - var earliestUsageDate = earliestUsageDateUtc == DateTime.MinValue - ? DateTime.MinValue - : earliestUsageDateUtc.ToUniversalTime().StartOfMonth(); - if (earliestUsageDate > startDate) - startDate = earliestUsageDate; - - int limit = usages - .Where(u => u.Date <= startDate) - .OrderByDescending(u => u.Date) - .FirstOrDefault()?.Limit - ?? usages.OrderBy(u => u.Date).FirstOrDefault()?.Limit - ?? defaultLimit; - - while (startDate <= endDate) - { - limit = usages.GetUsage(startDate, limit).Limit; - startDate = startDate.AddMonths(1).StartOfMonth(); - } - } - public static UsageInfo GetUsage(this ICollection usages, DateTime dateUtc, int limit) { var startOfMonth = dateUtc.ToUniversalTime().StartOfMonth(); diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 55fa677fc1..87bcf60c00 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -76,7 +76,62 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider) { - organization.Usage.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), organization.CreatedUtc, timeProvider); + var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); + var startDateUtc = endDateUtc.SubtractMonths(11); + var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); + if (organizationCreatedMonthUtc > startDateUtc) + startDateUtc = organizationCreatedMonthUtc; + + var knownUsages = organization.Usage + .Where(u => u.Limit != 0) + .OrderBy(u => u.Date) + .ToList(); + int limit = knownUsages + .LastOrDefault(u => u.Date <= startDateUtc)?.Limit + ?? knownUsages.FirstOrDefault()?.Limit + ?? organization.GetMaxEventsPerMonthWithBonus(timeProvider); + + DateTime? bonusExpirationMonthUtc = organization.BonusExpiration?.ToUniversalTime().StartOfMonth(); + int limitAfterBonusExpiration = limit; + if (bonusExpirationMonthUtc.HasValue) + { + int baseLimit = organization.MaxEventsPerMonth <= 0 ? -1 : organization.MaxEventsPerMonth; + limitAfterBonusExpiration = knownUsages + .FirstOrDefault(u => u.Date >= bonusExpirationMonthUtc.Value)?.Limit + ?? baseLimit; + + var firstKnownUsage = knownUsages.FirstOrDefault(); + if (startDateUtc < bonusExpirationMonthUtc.Value && firstKnownUsage is not null && firstKnownUsage.Date < bonusExpirationMonthUtc.Value) + limit = baseLimit; + else if (startDateUtc >= bonusExpirationMonthUtc.Value) + limit = limitAfterBonusExpiration; + } + + while (startDateUtc <= endDateUtc) + { + if (startDateUtc == bonusExpirationMonthUtc) + limit = limitAfterBonusExpiration; + + var usage = organization.Usage.FirstOrDefault(u => u.Date.Year == startDateUtc.Year && u.Date.Month == startDateUtc.Month); + if (usage is null) + { + organization.Usage.Add(new UsageInfo + { + Date = startDateUtc, + Limit = limit + }); + } + else if (usage.Limit == 0) + { + usage.Limit = limit; + } + else + { + limit = usage.Limit; + } + + startDateUtc = startDateUtc.AddMonths(1).StartOfMonth(); + } } public static UsageInfo GetCurrentUsage(this ViewOrganization organization, TimeProvider timeProvider) diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index ecfac809f1..09020b0b76 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -9,7 +9,31 @@ public class BillingManagerTests : TestWithServices public BillingManagerTests(ITestOutputHelper output) : base(output) { } [Fact] - public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsSinceOrganizationCreation() + public void ApplyBillingPlan_ExistingBonus_CreatesPreviousPlanAnchorWithBonus() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor() { // Arrange var billingManager = GetService(); @@ -27,13 +51,32 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_BackfillsSinceOrganization billingManager.ApplyBillingPlan(organization, plans.MediumPlan); // Assert - Assert.Equal(3, organization.Usage.Count); + Assert.Equal(2, organization.Usage.Count); Assert.DoesNotContain(organization.Usage, usage => usage.Date < new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); - Assert.All(organization.Usage.Where(u => u.Date < new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)), - usage => Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit)); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void ApplyBillingPlan_MissingStoredLimit_ResolvesPreviousPlanLimit() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void ApplyBillingPlan_NewOrganization_DoesNotInventPreviousPlanHistory() { @@ -53,6 +96,51 @@ public void ApplyBillingPlan_NewOrganization_DoesNotInventPreviousPlanHistory() Assert.Equal(plans.FreePlan.MaxEventsPerMonth, usage.Limit); } + [Fact] + public void ApplyBillingPlan_OrganizationCreatedThisMonth_DoesNotInventPreviousPlanHistory() + { + // 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, 6, 10, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.FreePlan.Id, + MaxEventsPerMonth = plans.FreePlan.MaxEventsPerMonth + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + } + + [Fact] + public void ApplyBillingPlan_UnknownPreviousPlan_DoesNotInventPreviousPlanHistory() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = "missing-plan" + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + } + [Fact] public void GetBillingPlan() { diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs new file mode 100644 index 0000000000..8fff1beaa6 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -0,0 +1,200 @@ +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_AppliesBonusToMissingMonths() + { + // 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_ExpiredBonus_DoesNotCarryBonusIntoLaterMonths() + { + // 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, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(20_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotCarryBonusIntoWindow() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2027, 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, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(12, organization.Usage.Count(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc))); + Assert.All(organization.Usage.Where(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)), + usage => Assert.Equal(15_000, usage.Limit)); + } + + [Fact] + public void EnsureUsage_PreBonusLimit_DoesNotSubtractBonusAgain() + { + // 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, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.All(organization.Usage, usage => Assert.Equal(15_000, usage.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, 3, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 250_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 250_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 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); + Assert.Equal(250_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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = -1 + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3, 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, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 }, + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void GetUsage_ExistingUsage_PreservesKnownLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var existingUsage = new UsageInfo + { + Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), + Limit = 15_000 + }; + var organization = new ViewOrganization + { + MaxEventsPerMonth = 75_000, + Usage = [existingUsage] + }; + + // Act + var usage = organization.GetUsage(existingUsage.Date, _timeProvider); + + // Assert + Assert.Same(existingUsage, usage); + Assert.Equal(15_000, usage.Limit); + } +} From cb302a47e3e1e86373db6300f9c273f19e9fa45f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 13:18:07 -0500 Subject: [PATCH 09/26] Keep usage history fix scoped --- .../Extensions/OrganizationExtensions.cs | 14 +++++++++++++- .../Models/Project/ViewProject.cs | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs index 36953119ec..84b949f3fc 100644 --- a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs +++ b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs @@ -145,6 +145,18 @@ public static UsageInfo GetCurrentUsage(this Organization organization, TimeProv public static UsageInfo GetUsage(this Organization organization, DateTime date, TimeProvider timeProvider) { - return organization.Usage.GetUsage(date, organization.GetMaxEventsPerMonthWithBonus(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; } } diff --git a/src/Exceptionless.Web/Models/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index bf8826b03a..52f5576e9a 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -1,5 +1,4 @@ using Exceptionless.Core.Attributes; -using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.DateTimeExtensions; using Foundatio.Repositories.Models; @@ -68,7 +67,18 @@ public static UsageInfo GetCurrentUsage(this ViewProject project, int limit, Tim public static UsageInfo GetUsage(this ViewProject project, DateTime date, int limit) { - return project.Usage.GetUsage(date, 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; } public static void TrimUsage(this ViewProject project, TimeProvider timeProvider) From 0fe417d685e31d6798880c6cd7b38b3a23a87da0 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 13:30:56 -0500 Subject: [PATCH 10/26] Anchor bonuses to historical month --- .../Billing/BillingManager.cs | 6 ++--- .../Billing/BillingManagerTests.cs | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 0bc09fb80b..6a2b0f02b0 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -110,14 +110,14 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? if (!String.IsNullOrEmpty(organization.PlanId)) { var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + var currentMonthUtc = utcNow.StartOfMonth(); + var previousMonthUtc = currentMonthUtc.AddMonths(-1); int previousLimit = organization.MaxEventsPerMonth != 0 ? organization.MaxEventsPerMonth : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; - if (previousLimit > 0 && organization.BonusExpiration > utcNow) + if (previousLimit > 0 && organization.BonusExpiration > previousMonthUtc) previousLimit += organization.BonusEventsPerMonth; - var currentMonthUtc = utcNow.StartOfMonth(); - var previousMonthUtc = currentMonthUtc.AddMonths(-1); var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); if (previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) organization.Usage.GetUsage(previousMonthUtc, previousLimit); diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 09020b0b76..e212e49679 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -57,6 +57,30 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor( Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void ApplyBillingPlan_ExpiredCurrentMonth_CreatesPreviousPlanAnchorWithBonus() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 6, 5, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void ApplyBillingPlan_MissingStoredLimit_ResolvesPreviousPlanLimit() { From 1f0e75992f7557a1ae3f4afef328dc7cc77bf727 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 13:45:49 -0500 Subject: [PATCH 11/26] Handle usage bonus month boundaries --- .../Billing/BillingManager.cs | 3 ++- .../Models/Organization/ViewOrganization.cs | 18 +++++++++---- .../Billing/BillingManagerTests.cs | 24 +++++++++++++++++ .../ViewOrganizationExtensionsTests.cs | 26 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 6a2b0f02b0..53f5534568 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -115,7 +115,8 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? int previousLimit = organization.MaxEventsPerMonth != 0 ? organization.MaxEventsPerMonth : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; - if (previousLimit > 0 && organization.BonusExpiration > previousMonthUtc) + var bonusExpirationMonthUtc = organization.BonusExpiration?.ToUniversalTime().StartOfMonth(); + if (previousLimit > 0 && bonusExpirationMonthUtc > previousMonthUtc) previousLimit += organization.BonusEventsPerMonth; var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 87bcf60c00..a8c4672928 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -96,15 +96,23 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider if (bonusExpirationMonthUtc.HasValue) { int baseLimit = organization.MaxEventsPerMonth <= 0 ? -1 : organization.MaxEventsPerMonth; - limitAfterBonusExpiration = knownUsages - .FirstOrDefault(u => u.Date >= bonusExpirationMonthUtc.Value)?.Limit - ?? baseLimit; + var usageAtBonusExpiration = knownUsages.FirstOrDefault(u => + u.Date.Year == bonusExpirationMonthUtc.Value.Year && u.Date.Month == bonusExpirationMonthUtc.Value.Month); + var usageBeforeBonusExpiration = knownUsages.LastOrDefault(u => u.Date < bonusExpirationMonthUtc.Value); + int limitBeforeBonusExpiration = usageBeforeBonusExpiration?.Limit ?? baseLimit; + limitAfterBonusExpiration = usageAtBonusExpiration?.Limit ?? GetLimitWithoutBonus(limitBeforeBonusExpiration); var firstKnownUsage = knownUsages.FirstOrDefault(); if (startDateUtc < bonusExpirationMonthUtc.Value && firstKnownUsage is not null && firstKnownUsage.Date < bonusExpirationMonthUtc.Value) - limit = baseLimit; - else if (startDateUtc >= bonusExpirationMonthUtc.Value) + limit = GetLimitWithoutBonus(firstKnownUsage.Limit); + else if (startDateUtc >= bonusExpirationMonthUtc.Value + && !knownUsages.Any(u => u.Date >= bonusExpirationMonthUtc.Value && u.Date <= startDateUtc)) limit = limitAfterBonusExpiration; + + int GetLimitWithoutBonus(int knownLimit) => + knownLimit > organization.BonusEventsPerMonth && knownLimit != baseLimit + ? knownLimit - organization.BonusEventsPerMonth + : knownLimit; } while (startDateUtc <= endDateUtc) diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index e212e49679..218dbf7738 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -81,6 +81,30 @@ public void ApplyBillingPlan_ExpiredCurrentMonth_CreatesPreviousPlanAnchorWithBo organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void ApplyBillingPlan_ExpiredPreviousMonth_CreatesPreviousPlanAnchorWithoutBonus() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void ApplyBillingPlan_MissingStoredLimit_ResolvesPreviousPlanLimit() { diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index 8fff1beaa6..eb19fcb29e 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -30,6 +30,32 @@ public void EnsureUsage_ActiveBonus_AppliesBonusToMissingMonths() Assert.All(organization.Usage, usage => Assert.Equal(20_000, usage.Limit)); } + [Fact] + public void EnsureUsage_ExpiredBonus_DoesNotBackfillFromFuturePlan() + { + // 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 = 75_000, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void EnsureUsage_ExpiredBonus_DoesNotCarryBonusIntoLaterMonths() { From 79c99bfc7fcc18402e1f24e41a8d4574b008fdec Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 13:58:41 -0500 Subject: [PATCH 12/26] Disambiguate equal historical limits --- .../Models/Organization/ViewOrganization.cs | 4 ++- .../ViewOrganizationExtensionsTests.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index a8c4672928..75376550f5 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -99,6 +99,8 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var usageAtBonusExpiration = knownUsages.FirstOrDefault(u => u.Date.Year == bonusExpirationMonthUtc.Value.Year && u.Date.Month == bonusExpirationMonthUtc.Value.Month); var usageBeforeBonusExpiration = knownUsages.LastOrDefault(u => u.Date < bonusExpirationMonthUtc.Value); + var billingChangeMonthUtc = organization.BillingChangeDate?.ToUniversalTime().StartOfMonth(); + bool currentPlanStartedAfterBonusExpiration = billingChangeMonthUtc > bonusExpirationMonthUtc; int limitBeforeBonusExpiration = usageBeforeBonusExpiration?.Limit ?? baseLimit; limitAfterBonusExpiration = usageAtBonusExpiration?.Limit ?? GetLimitWithoutBonus(limitBeforeBonusExpiration); @@ -110,7 +112,7 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider limit = limitAfterBonusExpiration; int GetLimitWithoutBonus(int knownLimit) => - knownLimit > organization.BonusEventsPerMonth && knownLimit != baseLimit + knownLimit > organization.BonusEventsPerMonth && (knownLimit != baseLimit || currentPlanStartedAfterBonusExpiration) ? knownLimit - organization.BonusEventsPerMonth : knownLimit; } diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index eb19fcb29e..60a5ddfea3 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -30,6 +30,32 @@ public void EnsureUsage_ActiveBonus_AppliesBonusToMissingMonths() Assert.All(organization.Usage, usage => Assert.Equal(20_000, usage.Limit)); } + [Fact] + public void EnsureUsage_ExpiredBonus_DistinguishesPriorBonusFromFuturePlan() + { + // 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 = 75_000, + BonusEventsPerMonth = 60_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void EnsureUsage_ExpiredBonus_DoesNotBackfillFromFuturePlan() { @@ -41,6 +67,7 @@ public void EnsureUsage_ExpiredBonus_DoesNotBackfillFromFuturePlan() MaxEventsPerMonth = 75_000, BonusEventsPerMonth = 5_000, BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Usage = [ new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 }, From 733df8d002b05dc4258762eac0e4b7d2dd68fc25 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 14:10:22 -0500 Subject: [PATCH 13/26] Repair zero-valued usage anchors --- .../Billing/BillingManager.cs | 6 ++++- .../Billing/BillingManagerTests.cs | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 53f5534568..ebee90836b 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -121,7 +121,11 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); if (previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) - organization.Usage.GetUsage(previousMonthUtc, previousLimit); + { + var previousUsage = organization.Usage.GetUsage(previousMonthUtc, previousLimit); + if (previousUsage.Limit == 0) + previousUsage.Limit = previousLimit; + } } organization.PlanId = plan.Id; diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 218dbf7738..c713994315 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -57,6 +57,32 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor( Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + ] + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void ApplyBillingPlan_ExpiredCurrentMonth_CreatesPreviousPlanAnchorWithBonus() { From fc1eb7b58cee5726edb2404c43daf1930f4b8735 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 14:24:19 -0500 Subject: [PATCH 14/26] Anchor only confirmed plan history --- .../Billing/BillingManager.cs | 7 +- .../Billing/BillingManagerTests.cs | 87 ++++++++++--------- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index ebee90836b..0ff4b6b5ec 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -115,12 +115,9 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? int previousLimit = organization.MaxEventsPerMonth != 0 ? organization.MaxEventsPerMonth : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; - var bonusExpirationMonthUtc = organization.BonusExpiration?.ToUniversalTime().StartOfMonth(); - if (previousLimit > 0 && bonusExpirationMonthUtc > previousMonthUtc) - previousLimit += organization.BonusEventsPerMonth; - var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); - if (previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) + bool planLimitChanged = organization.PlanId != plan.Id || previousLimit != plan.MaxEventsPerMonth; + if (planLimitChanged && previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) { var previousUsage = organization.Usage.GetUsage(previousMonthUtc, previousLimit); if (previousUsage.Limit == 0) diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index c713994315..d5fd38865e 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -9,7 +9,7 @@ public class BillingManagerTests : TestWithServices public BillingManagerTests(ITestOutputHelper output) : base(output) { } [Fact] - public void ApplyBillingPlan_ExistingBonus_CreatesPreviousPlanAnchorWithBonus() + public void ApplyBillingPlan_BonusWithoutUsage_CreatesPreviousPlanAnchorWithoutBonus() { // Arrange var billingManager = GetService(); @@ -28,37 +28,12 @@ public void ApplyBillingPlan_ExistingBonus_CreatesPreviousPlanAnchorWithBonus() billingManager.ApplyBillingPlan(organization, plans.MediumPlan); // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } [Fact] - public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor() - { - // Arrange - var billingManager = GetService(); - var plans = GetService(); - var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); - TimeProvider.SetUtcNow(utcNow); - var organization = new Organization - { - CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.SmallPlan.Id, - MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth - }; - - // 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(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() + public void ApplyBillingPlan_ExistingBonus_PreservesPreviousPlanAnchorWithBonus() { // Arrange var billingManager = GetService(); @@ -69,9 +44,15 @@ public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), PlanId = plans.SmallPlan.Id, MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), Usage = [ - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + new UsageInfo + { + Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), + Limit = plans.SmallPlan.MaxEventsPerMonth + 5_000 + } ] }; @@ -79,36 +60,37 @@ public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() billingManager.ApplyBillingPlan(organization, plans.MediumPlan); // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } [Fact] - public void ApplyBillingPlan_ExpiredCurrentMonth_CreatesPreviousPlanAnchorWithBonus() + public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor() { // Arrange var billingManager = GetService(); var plans = GetService(); - TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(utcNow); var organization = new Organization { CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), PlanId = plans.SmallPlan.Id, - MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 6, 5, 0, 0, 0, DateTimeKind.Utc) + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth }; // Act billingManager.ApplyBillingPlan(organization, plans.MediumPlan); // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, - organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + 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(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } [Fact] - public void ApplyBillingPlan_ExpiredPreviousMonth_CreatesPreviousPlanAnchorWithoutBonus() + public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() { // Arrange var billingManager = GetService(); @@ -119,8 +101,10 @@ public void ApplyBillingPlan_ExpiredPreviousMonth_CreatesPreviousPlanAnchorWitho CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), PlanId = plans.SmallPlan.Id, MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc) + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + ] }; // Act @@ -193,6 +177,29 @@ public void ApplyBillingPlan_OrganizationCreatedThisMonth_DoesNotInventPreviousP Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); } + [Fact] + public void ApplyBillingPlan_UnchangedPlan_DoesNotCreatePreviousPlanAnchor() + { + // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + } + [Fact] public void ApplyBillingPlan_UnknownPreviousPlan_DoesNotInventPreviousPlanHistory() { From bc3c8b1faa499fb5934c022a3e94d7915e4f639d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 14:36:24 -0500 Subject: [PATCH 15/26] Compare plan identifiers consistently --- src/Exceptionless.Core/Billing/BillingManager.cs | 3 ++- tests/Exceptionless.Tests/Billing/BillingManagerTests.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 0ff4b6b5ec..05bf782b31 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -116,7 +116,8 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? ? organization.MaxEventsPerMonth : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); - bool planLimitChanged = organization.PlanId != plan.Id || previousLimit != plan.MaxEventsPerMonth; + bool planLimitChanged = !String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) + || previousLimit != plan.MaxEventsPerMonth; if (planLimitChanged && previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) { var previousUsage = organization.Usage.GetUsage(previousMonthUtc, previousLimit); diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index d5fd38865e..723c6e9162 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -187,7 +187,7 @@ public void ApplyBillingPlan_UnchangedPlan_DoesNotCreatePreviousPlanAnchor() var organization = new Organization { CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.SmallPlan.Id, + PlanId = plans.SmallPlan.Id.ToUpperInvariant(), MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth }; From 51888cdd761c1f45a7071af15197725b689ef18e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 14:48:01 -0500 Subject: [PATCH 16/26] Preserve usage fallback base limits --- .../Models/Organization/ViewOrganization.cs | 4 ++-- .../ViewOrganizationExtensionsTests.cs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 75376550f5..5ac503335f 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -101,8 +101,8 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var usageBeforeBonusExpiration = knownUsages.LastOrDefault(u => u.Date < bonusExpirationMonthUtc.Value); var billingChangeMonthUtc = organization.BillingChangeDate?.ToUniversalTime().StartOfMonth(); bool currentPlanStartedAfterBonusExpiration = billingChangeMonthUtc > bonusExpirationMonthUtc; - int limitBeforeBonusExpiration = usageBeforeBonusExpiration?.Limit ?? baseLimit; - limitAfterBonusExpiration = usageAtBonusExpiration?.Limit ?? GetLimitWithoutBonus(limitBeforeBonusExpiration); + limitAfterBonusExpiration = usageAtBonusExpiration?.Limit + ?? (usageBeforeBonusExpiration is not null ? GetLimitWithoutBonus(usageBeforeBonusExpiration.Limit) : baseLimit); var firstKnownUsage = knownUsages.FirstOrDefault(); if (startDateUtc < bonusExpirationMonthUtc.Value && firstKnownUsage is not null && firstKnownUsage.Date < bonusExpirationMonthUtc.Value) diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index 60a5ddfea3..623a3332e1 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -136,6 +136,29 @@ public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotCarryBonusIntoWindow() usage => Assert.Equal(15_000, usage.Limit)); } + [Fact] + public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotSubtractBonusFromFallback() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2027, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 75_000, + BonusEventsPerMonth = 60_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(12, organization.Usage.Count(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc))); + Assert.All(organization.Usage.Where(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)), + usage => Assert.Equal(75_000, usage.Limit)); + } + [Fact] public void EnsureUsage_PreBonusLimit_DoesNotSubtractBonusAgain() { From 412b87d1f5ad3ad8f86e41a1606f0d42128acf80 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 15:00:28 -0500 Subject: [PATCH 17/26] Preserve outgoing usage anchors with new bonuses --- .../Models/Organization/ViewOrganization.cs | 3 ++- .../ViewOrganizationExtensionsTests.cs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 5ac503335f..0c64da890b 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -112,7 +112,8 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider limit = limitAfterBonusExpiration; int GetLimitWithoutBonus(int knownLimit) => - knownLimit > organization.BonusEventsPerMonth && (knownLimit != baseLimit || currentPlanStartedAfterBonusExpiration) + knownLimit > organization.BonusEventsPerMonth + && (knownLimit == baseLimit + organization.BonusEventsPerMonth || currentPlanStartedAfterBonusExpiration) ? knownLimit - organization.BonusEventsPerMonth : knownLimit; } diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index 623a3332e1..eae159695c 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -30,6 +30,33 @@ public void EnsureUsage_ActiveBonus_AppliesBonusToMissingMonths() Assert.All(organization.Usage, usage => Assert.Equal(20_000, usage.Limit)); } + [Fact] + public void EnsureUsage_ActiveBonus_DoesNotSubtractBonusFromOutgoingPlanAnchor() + { + // 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, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void EnsureUsage_ExpiredBonus_DistinguishesPriorBonusFromFuturePlan() { From b45b3761fb205485ea81fb76d2d07ca4072194b5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 15:14:09 -0500 Subject: [PATCH 18/26] Stop expired bonus limits from carrying forward --- .../Models/Organization/ViewOrganization.cs | 16 +++--- .../ViewOrganizationExtensionsTests.cs | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 0c64da890b..0d0fbffeae 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -101,19 +101,23 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var usageBeforeBonusExpiration = knownUsages.LastOrDefault(u => u.Date < bonusExpirationMonthUtc.Value); var billingChangeMonthUtc = organization.BillingChangeDate?.ToUniversalTime().StartOfMonth(); bool currentPlanStartedAfterBonusExpiration = billingChangeMonthUtc > bonusExpirationMonthUtc; - limitAfterBonusExpiration = usageAtBonusExpiration?.Limit - ?? (usageBeforeBonusExpiration is not null ? GetLimitWithoutBonus(usageBeforeBonusExpiration.Limit) : baseLimit); + limitAfterBonusExpiration = usageAtBonusExpiration is not null + ? GetLimitWithoutBonus(usageAtBonusExpiration.Limit, + currentPlanStartedAfterBonusExpiration && usageBeforeBonusExpiration?.Limit == usageAtBonusExpiration.Limit) + : usageBeforeBonusExpiration is not null + ? GetLimitWithoutBonus(usageBeforeBonusExpiration.Limit, currentPlanStartedAfterBonusExpiration) + : baseLimit; var firstKnownUsage = knownUsages.FirstOrDefault(); if (startDateUtc < bonusExpirationMonthUtc.Value && firstKnownUsage is not null && firstKnownUsage.Date < bonusExpirationMonthUtc.Value) - limit = GetLimitWithoutBonus(firstKnownUsage.Limit); + limit = GetLimitWithoutBonus(firstKnownUsage.Limit, currentPlanStartedAfterBonusExpiration); else if (startDateUtc >= bonusExpirationMonthUtc.Value && !knownUsages.Any(u => u.Date >= bonusExpirationMonthUtc.Value && u.Date <= startDateUtc)) limit = limitAfterBonusExpiration; - int GetLimitWithoutBonus(int knownLimit) => + int GetLimitWithoutBonus(int knownLimit, bool inferHistoricalBonus) => knownLimit > organization.BonusEventsPerMonth - && (knownLimit == baseLimit + organization.BonusEventsPerMonth || currentPlanStartedAfterBonusExpiration) + && (knownLimit == baseLimit + organization.BonusEventsPerMonth || inferHistoricalBonus) ? knownLimit - organization.BonusEventsPerMonth : knownLimit; } @@ -136,7 +140,7 @@ int GetLimitWithoutBonus(int knownLimit) => { usage.Limit = limit; } - else + else if (startDateUtc != bonusExpirationMonthUtc) { limit = usage.Limit; } diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index eae159695c..91215bdbf4 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -137,6 +137,59 @@ public void EnsureUsage_ExpiredBonus_DoesNotCarryBonusIntoLaterMonths() Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void EnsureUsage_ExpiredBonus_DoesNotCarryExpirationMonthBonusForward() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 7, 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, 5, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(20_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_ExpiredBonus_DoesNotSubtractExpirationMonthBaseAfterPlanChange() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 75_000, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 }, + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 }, + new UsageInfo { Date = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotCarryBonusIntoWindow() { From 2946748f2fd17b40563e6d5535d1a54f23e614c9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 15:26:13 -0500 Subject: [PATCH 19/26] Preserve equal-valued outgoing usage anchors --- .../Models/Organization/ViewOrganization.cs | 19 +++++++------ .../ViewOrganizationExtensionsTests.cs | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 0d0fbffeae..df7b277fce 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -102,24 +102,27 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var billingChangeMonthUtc = organization.BillingChangeDate?.ToUniversalTime().StartOfMonth(); bool currentPlanStartedAfterBonusExpiration = billingChangeMonthUtc > bonusExpirationMonthUtc; limitAfterBonusExpiration = usageAtBonusExpiration is not null - ? GetLimitWithoutBonus(usageAtBonusExpiration.Limit, + ? GetLimitWithoutBonus(usageAtBonusExpiration, currentPlanStartedAfterBonusExpiration && usageBeforeBonusExpiration?.Limit == usageAtBonusExpiration.Limit) : usageBeforeBonusExpiration is not null - ? GetLimitWithoutBonus(usageBeforeBonusExpiration.Limit, currentPlanStartedAfterBonusExpiration) + ? GetLimitWithoutBonus(usageBeforeBonusExpiration, currentPlanStartedAfterBonusExpiration) : baseLimit; var firstKnownUsage = knownUsages.FirstOrDefault(); if (startDateUtc < bonusExpirationMonthUtc.Value && firstKnownUsage is not null && firstKnownUsage.Date < bonusExpirationMonthUtc.Value) - limit = GetLimitWithoutBonus(firstKnownUsage.Limit, currentPlanStartedAfterBonusExpiration); + limit = GetLimitWithoutBonus(firstKnownUsage, currentPlanStartedAfterBonusExpiration); else if (startDateUtc >= bonusExpirationMonthUtc.Value && !knownUsages.Any(u => u.Date >= bonusExpirationMonthUtc.Value && u.Date <= startDateUtc)) limit = limitAfterBonusExpiration; - int GetLimitWithoutBonus(int knownLimit, bool inferHistoricalBonus) => - knownLimit > organization.BonusEventsPerMonth - && (knownLimit == baseLimit + organization.BonusEventsPerMonth || inferHistoricalBonus) - ? knownLimit - organization.BonusEventsPerMonth - : knownLimit; + int GetLimitWithoutBonus(UsageInfo knownUsage, bool inferHistoricalBonus) + { + bool currentPlanWasActive = !billingChangeMonthUtc.HasValue || knownUsage.Date >= billingChangeMonthUtc.Value; + return knownUsage.Limit > organization.BonusEventsPerMonth + && (currentPlanWasActive && knownUsage.Limit == baseLimit + organization.BonusEventsPerMonth || inferHistoricalBonus) + ? knownUsage.Limit - organization.BonusEventsPerMonth + : knownUsage.Limit; + } } while (startDateUtc <= endDateUtc) diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index 91215bdbf4..faa0bb2d54 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -57,6 +57,33 @@ public void EnsureUsage_ActiveBonus_DoesNotSubtractBonusFromOutgoingPlanAnchor() Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void EnsureUsage_ActiveBonus_PreservesEqualValuedOutgoingPlanAnchor() + { + // 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, + BonusEventsPerMonth = 60_000, + BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 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, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public void EnsureUsage_ExpiredBonus_DistinguishesPriorBonusFromFuturePlan() { From 53696f56767e6dd4c47e92e18907aa7a4ea78b88 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 15:38:49 -0500 Subject: [PATCH 20/26] Preserve billing change dates during maintenance --- .../Billing/BillingManager.cs | 16 +++++++------ .../Billing/BillingManagerTests.cs | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 05bf782b31..e88749a138 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -107,17 +107,18 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + int previousLimit = organization.MaxEventsPerMonth != 0 + ? organization.MaxEventsPerMonth + : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; + bool planLimitChanged = !String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) + || previousLimit != plan.MaxEventsPerMonth; + if (!String.IsNullOrEmpty(organization.PlanId)) { - var utcNow = _timeProvider.GetUtcNow().UtcDateTime; var currentMonthUtc = utcNow.StartOfMonth(); var previousMonthUtc = currentMonthUtc.AddMonths(-1); - int previousLimit = organization.MaxEventsPerMonth != 0 - ? organization.MaxEventsPerMonth - : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); - bool planLimitChanged = !String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) - || previousLimit != plan.MaxEventsPerMonth; if (planLimitChanged && previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) { var previousUsage = organization.Usage.GetUsage(previousMonthUtc, previousLimit); @@ -129,7 +130,8 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? organization.PlanId = plan.Id; organization.PlanName = plan.Name; organization.PlanDescription = plan.Description; - organization.BillingChangeDate = _timeProvider.GetUtcNow().UtcDateTime; + if (planLimitChanged) + organization.BillingChangeDate = utcNow; if (updateBillingPrice) organization.BillingPrice = plan.Price; diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 723c6e9162..939ffec373 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -200,6 +200,29 @@ public void ApplyBillingPlan_UnchangedPlan_DoesNotCreatePreviousPlanAnchor() Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); } + [Fact] + public void ApplyBillingPlan_UnchangedPlan_PreservesBillingChangeDate() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var billingChangeDate = new DateTime(2026, 4, 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 = billingChangeDate + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + Assert.Equal(billingChangeDate, organization.BillingChangeDate); + } + [Fact] public void ApplyBillingPlan_UnknownPreviousPlan_DoesNotInventPreviousPlanHistory() { From 3e746d0ed23d39da85f071491f7a93b1b54450e8 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 15:51:06 -0500 Subject: [PATCH 21/26] Treat missing billing history as unknown --- .../Models/Organization/ViewOrganization.cs | 7 ++++-- .../ViewOrganizationExtensionsTests.cs | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index df7b277fce..c227666a3e 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -99,7 +99,10 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var usageAtBonusExpiration = knownUsages.FirstOrDefault(u => u.Date.Year == bonusExpirationMonthUtc.Value.Year && u.Date.Month == bonusExpirationMonthUtc.Value.Month); var usageBeforeBonusExpiration = knownUsages.LastOrDefault(u => u.Date < bonusExpirationMonthUtc.Value); - var billingChangeMonthUtc = organization.BillingChangeDate?.ToUniversalTime().StartOfMonth(); + DateTime? billingChangeMonthUtc = organization.BillingChangeDate is { } billingChangeDate + && billingChangeDate > DateTime.MinValue + ? billingChangeDate.ToUniversalTime().StartOfMonth() + : null; bool currentPlanStartedAfterBonusExpiration = billingChangeMonthUtc > bonusExpirationMonthUtc; limitAfterBonusExpiration = usageAtBonusExpiration is not null ? GetLimitWithoutBonus(usageAtBonusExpiration, @@ -117,7 +120,7 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider int GetLimitWithoutBonus(UsageInfo knownUsage, bool inferHistoricalBonus) { - bool currentPlanWasActive = !billingChangeMonthUtc.HasValue || knownUsage.Date >= billingChangeMonthUtc.Value; + bool currentPlanWasActive = billingChangeMonthUtc.HasValue && knownUsage.Date >= billingChangeMonthUtc.Value; return knownUsage.Limit > organization.BonusEventsPerMonth && (currentPlanWasActive && knownUsage.Limit == baseLimit + organization.BonusEventsPerMonth || inferHistoricalBonus) ? knownUsage.Limit - organization.BonusEventsPerMonth diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index faa0bb2d54..d9e3e1cb8f 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -84,6 +84,27 @@ public void EnsureUsage_ActiveBonus_PreservesEqualValuedOutgoingPlanAnchor() Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } + [Fact] + public void EnsureUsage_ActiveBonus_PreservesEqualValuedOutgoingPlanAnchorWithoutBillingHistory() + { + // 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); + } + [Fact] public void EnsureUsage_ExpiredBonus_DistinguishesPriorBonusFromFuturePlan() { @@ -148,6 +169,7 @@ public void EnsureUsage_ExpiredBonus_DoesNotCarryBonusIntoLaterMonths() MaxEventsPerMonth = 15_000, BonusEventsPerMonth = 5_000, BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), Usage = [ new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } @@ -175,6 +197,7 @@ public void EnsureUsage_ExpiredBonus_DoesNotCarryExpirationMonthBonusForward() MaxEventsPerMonth = 15_000, BonusEventsPerMonth = 5_000, BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), Usage = [ new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } @@ -228,6 +251,7 @@ public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotCarryBonusIntoWindow() MaxEventsPerMonth = 15_000, BonusEventsPerMonth = 5_000, BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + BillingChangeDate = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), Usage = [ new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } From 7695a18e192f266bb9ca18adc3ab28f829db9600 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 16:03:20 -0500 Subject: [PATCH 22/26] Bound project usage to creation dates --- .../Api/Handlers/ProjectHandler.cs | 2 +- .../Models/Project/ViewProject.cs | 16 ++++++---- .../Extensions/ViewProjectExtensionsTests.cs | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 3705100472..969088f65c 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/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index 52f5576e9a..07cbf277e7 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -49,14 +49,20 @@ 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 createdMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth(); + if (project.CreatedUtc > organizationCreatedUtc) + createdMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth(); + if (createdMonthUtc > startDateUtc) + startDateUtc = createdMonthUtc; - while (startDate < timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) + while (startDateUtc < endDateUtc) { - project.GetUsage(startDate, limit); - startDate = startDate.AddMonths(1).StartOfMonth(); + project.GetUsage(startDateUtc, limit); + startDateUtc = startDateUtc.AddMonths(1).StartOfMonth(); } } diff --git a/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs new file mode 100644 index 0000000000..62978e5e00 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs @@ -0,0 +1,29 @@ +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_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)); + } +} From 9c402099de46b7aeca4c40c5294f4247797e84d1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 17:33:13 -0500 Subject: [PATCH 23/26] Simplify usage history backfill --- .../Billing/BillingManager.cs | 49 +-- .../Extensions/UsageExtensions.cs | 2 + .../Models/Organization/ViewOrganization.cs | 56 +--- .../Models/Project/ViewProject.cs | 4 + .../Billing/BillingManagerTests.cs | 35 +- .../ViewOrganizationExtensionsTests.cs | 302 ++---------------- 6 files changed, 67 insertions(+), 381 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index e88749a138..142975834b 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -108,36 +108,22 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - int previousLimit = organization.MaxEventsPerMonth != 0 - ? organization.MaxEventsPerMonth - : GetBillingPlan(organization.PlanId)?.MaxEventsPerMonth ?? 0; - bool planLimitChanged = !String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) - || previousLimit != plan.MaxEventsPerMonth; - - if (!String.IsNullOrEmpty(organization.PlanId)) - { - var currentMonthUtc = utcNow.StartOfMonth(); - var previousMonthUtc = currentMonthUtc.AddMonths(-1); - var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); - if (planLimitChanged && previousLimit != 0 && previousMonthUtc >= organizationCreatedMonthUtc) - { - var previousUsage = organization.Usage.GetUsage(previousMonthUtc, previousLimit); - if (previousUsage.Limit == 0) - previousUsage.Limit = previousLimit; - } - } + CaptureOutgoingUsageLimit(organization, plan, utcNow); organization.PlanId = plan.Id; organization.PlanName = plan.Name; organization.PlanDescription = plan.Description; - if (planLimitChanged) - organization.BillingChangeDate = utcNow; + 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; @@ -148,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.GetUsage(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/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index a030d58803..31ebc3133a 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -10,7 +10,9 @@ public static UsageInfo GetUsage(this ICollection usages, DateTime da 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 { diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index c227666a3e..0cdb147c47 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -80,7 +80,9 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var startDateUtc = endDateUtc.SubtractMonths(11); var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); if (organizationCreatedMonthUtc > startDateUtc) + { startDateUtc = organizationCreatedMonthUtc; + } var knownUsages = organization.Usage .Where(u => u.Limit != 0) @@ -91,62 +93,14 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider ?? knownUsages.FirstOrDefault()?.Limit ?? organization.GetMaxEventsPerMonthWithBonus(timeProvider); - DateTime? bonusExpirationMonthUtc = organization.BonusExpiration?.ToUniversalTime().StartOfMonth(); - int limitAfterBonusExpiration = limit; - if (bonusExpirationMonthUtc.HasValue) - { - int baseLimit = organization.MaxEventsPerMonth <= 0 ? -1 : organization.MaxEventsPerMonth; - var usageAtBonusExpiration = knownUsages.FirstOrDefault(u => - u.Date.Year == bonusExpirationMonthUtc.Value.Year && u.Date.Month == bonusExpirationMonthUtc.Value.Month); - var usageBeforeBonusExpiration = knownUsages.LastOrDefault(u => u.Date < bonusExpirationMonthUtc.Value); - DateTime? billingChangeMonthUtc = organization.BillingChangeDate is { } billingChangeDate - && billingChangeDate > DateTime.MinValue - ? billingChangeDate.ToUniversalTime().StartOfMonth() - : null; - bool currentPlanStartedAfterBonusExpiration = billingChangeMonthUtc > bonusExpirationMonthUtc; - limitAfterBonusExpiration = usageAtBonusExpiration is not null - ? GetLimitWithoutBonus(usageAtBonusExpiration, - currentPlanStartedAfterBonusExpiration && usageBeforeBonusExpiration?.Limit == usageAtBonusExpiration.Limit) - : usageBeforeBonusExpiration is not null - ? GetLimitWithoutBonus(usageBeforeBonusExpiration, currentPlanStartedAfterBonusExpiration) - : baseLimit; - - var firstKnownUsage = knownUsages.FirstOrDefault(); - if (startDateUtc < bonusExpirationMonthUtc.Value && firstKnownUsage is not null && firstKnownUsage.Date < bonusExpirationMonthUtc.Value) - limit = GetLimitWithoutBonus(firstKnownUsage, currentPlanStartedAfterBonusExpiration); - else if (startDateUtc >= bonusExpirationMonthUtc.Value - && !knownUsages.Any(u => u.Date >= bonusExpirationMonthUtc.Value && u.Date <= startDateUtc)) - limit = limitAfterBonusExpiration; - - int GetLimitWithoutBonus(UsageInfo knownUsage, bool inferHistoricalBonus) - { - bool currentPlanWasActive = billingChangeMonthUtc.HasValue && knownUsage.Date >= billingChangeMonthUtc.Value; - return knownUsage.Limit > organization.BonusEventsPerMonth - && (currentPlanWasActive && knownUsage.Limit == baseLimit + organization.BonusEventsPerMonth || inferHistoricalBonus) - ? knownUsage.Limit - organization.BonusEventsPerMonth - : knownUsage.Limit; - } - } - while (startDateUtc <= endDateUtc) { - if (startDateUtc == bonusExpirationMonthUtc) - limit = limitAfterBonusExpiration; - - var usage = organization.Usage.FirstOrDefault(u => u.Date.Year == startDateUtc.Year && u.Date.Month == startDateUtc.Month); - if (usage is null) - { - organization.Usage.Add(new UsageInfo - { - Date = startDateUtc, - Limit = limit - }); - } - else if (usage.Limit == 0) + var usage = organization.Usage.GetUsage(startDateUtc, limit); + if (usage.Limit == 0) { usage.Limit = limit; } - else if (startDateUtc != bonusExpirationMonthUtc) + else { limit = usage.Limit; } diff --git a/src/Exceptionless.Web/Models/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index 07cbf277e7..34b7539f2c 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -55,9 +55,13 @@ public static void EnsureUsage(this ViewProject project, int limit, DateTime org var startDateUtc = endDateUtc.SubtractYears(1); var createdMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth(); if (project.CreatedUtc > organizationCreatedUtc) + { createdMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth(); + } if (createdMonthUtc > startDateUtc) + { startDateUtc = createdMonthUtc; + } while (startDateUtc < endDateUtc) { diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 939ffec373..8e4c7162b1 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -116,7 +116,7 @@ public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() } [Fact] - public void ApplyBillingPlan_MissingStoredLimit_ResolvesPreviousPlanLimit() + public void ApplyBillingPlan_MissingPreviousLimit_DoesNotInventPreviousPlanHistory() { // Arrange var billingManager = GetService(); @@ -132,7 +132,9 @@ public void ApplyBillingPlan_MissingStoredLimit_ResolvesPreviousPlanLimit() billingManager.ApplyBillingPlan(organization, plans.MediumPlan); // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, usage.Limit); } [Fact] @@ -201,48 +203,25 @@ public void ApplyBillingPlan_UnchangedPlan_DoesNotCreatePreviousPlanAnchor() } [Fact] - public void ApplyBillingPlan_UnchangedPlan_PreservesBillingChangeDate() + 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 billingChangeDate = new DateTime(2026, 4, 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 = billingChangeDate + BillingChangeDate = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc) }; // Act billingManager.ApplyBillingPlan(organization, plans.SmallPlan); // Assert - Assert.Equal(billingChangeDate, organization.BillingChangeDate); - } - - [Fact] - public void ApplyBillingPlan_UnknownPreviousPlan_DoesNotInventPreviousPlanHistory() - { - // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = "missing-plan" - }; - - // Act - billingManager.ApplyBillingPlan(organization, plans.SmallPlan); - - // Assert - var usage = Assert.Single(organization.Usage); - Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + Assert.Equal(TimeProvider.GetUtcNow().UtcDateTime, organization.BillingChangeDate); } [Fact] diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs index d9e3e1cb8f..0d6ab350df 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -10,7 +10,7 @@ public class ViewOrganizationExtensionsTests private readonly ProxyTimeProvider _timeProvider = new(); [Fact] - public void EnsureUsage_ActiveBonus_AppliesBonusToMissingMonths() + public void EnsureUsage_ActiveBonus_AppliesBonusWhenHistoryIsEmpty() { // Arrange _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); @@ -31,7 +31,7 @@ public void EnsureUsage_ActiveBonus_AppliesBonusToMissingMonths() } [Fact] - public void EnsureUsage_ActiveBonus_DoesNotSubtractBonusFromOutgoingPlanAnchor() + public void EnsureUsage_ExistingUsage_PrefillsFromOldestKnownLimit() { // Arrange _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); @@ -39,13 +39,10 @@ public void EnsureUsage_ActiveBonus_DoesNotSubtractBonusFromOutgoingPlanAnchor() { CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), MaxEventsPerMonth = 15_000, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Usage = [ - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, - new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } + 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 } ] }; @@ -53,39 +50,12 @@ public void EnsureUsage_ActiveBonus_DoesNotSubtractBonusFromOutgoingPlanAnchor() organization.EnsureUsage(_timeProvider); // Assert - Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ActiveBonus_PreservesEqualValuedOutgoingPlanAnchor() - { - // 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, - BonusEventsPerMonth = 60_000, - BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, - new UsageInfo { Date = new DateTime(2026, 6, 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, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + 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_ActiveBonus_PreservesEqualValuedOutgoingPlanAnchorWithoutBillingHistory() + public void EnsureUsage_NonzeroUsage_PreservesPersistedLimit() { // Arrange _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); @@ -103,215 +73,7 @@ public void EnsureUsage_ActiveBonus_PreservesEqualValuedOutgoingPlanAnchorWithou // Assert Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ExpiredBonus_DistinguishesPriorBonusFromFuturePlan() - { - // 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 = 75_000, - BonusEventsPerMonth = 60_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, - new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ExpiredBonus_DoesNotBackfillFromFuturePlan() - { - // 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 = 75_000, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 }, - new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ExpiredBonus_DoesNotCarryBonusIntoLaterMonths() - { - // 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, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(20_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ExpiredBonus_DoesNotCarryExpirationMonthBonusForward() - { - // Arrange - _timeProvider.SetUtcNow(new DateTime(2026, 7, 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, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(20_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ExpiredBonus_DoesNotSubtractExpirationMonthBaseAfterPlanChange() - { - // Arrange - _timeProvider.SetUtcNow(new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc)); - var organization = new ViewOrganization - { - CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), - MaxEventsPerMonth = 75_000, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 }, - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 }, - new UsageInfo { Date = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotCarryBonusIntoWindow() - { - // Arrange - _timeProvider.SetUtcNow(new DateTime(2027, 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, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 20_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(12, organization.Usage.Count(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc))); - Assert.All(organization.Usage.Where(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)), - usage => Assert.Equal(15_000, usage.Limit)); - } - - [Fact] - public void EnsureUsage_ExpiredBonusBeforeWindow_DoesNotSubtractBonusFromFallback() - { - // Arrange - _timeProvider.SetUtcNow(new DateTime(2027, 6, 15, 0, 0, 0, DateTimeKind.Utc)); - var organization = new ViewOrganization - { - CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), - MaxEventsPerMonth = 75_000, - BonusEventsPerMonth = 60_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - BillingChangeDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc) - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.Equal(12, organization.Usage.Count(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc))); - Assert.All(organization.Usage.Where(u => u.Date >= new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)), - usage => Assert.Equal(75_000, usage.Limit)); - } - - [Fact] - public void EnsureUsage_PreBonusLimit_DoesNotSubtractBonusAgain() - { - // 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, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 } - ] - }; - - // Act - organization.EnsureUsage(_timeProvider); - - // Assert - Assert.All(organization.Usage, usage => Assert.Equal(15_000, usage.Limit)); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } [Fact] @@ -321,12 +83,12 @@ public void EnsureUsage_SparseHistory_CarriesKnownLimitsBetweenChanges() _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 = 250_000, + CreatedUtc = new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 75_000, Usage = [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }, - new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 250_000 } + 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 } ] }; @@ -334,9 +96,9 @@ public void EnsureUsage_SparseHistory_CarriesKnownLimitsBetweenChanges() organization.EnsureUsage(_timeProvider); // Assert - Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 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); - Assert.Equal(250_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + 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] @@ -346,7 +108,7 @@ public void EnsureUsage_UnlimitedPlan_CarriesUnlimitedLimit() _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), + CreatedUtc = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), MaxEventsPerMonth = -1 }; @@ -354,7 +116,7 @@ public void EnsureUsage_UnlimitedPlan_CarriesUnlimitedLimit() organization.EnsureUsage(_timeProvider); // Assert - Assert.Equal(3, organization.Usage.Count); + Assert.Equal(2, organization.Usage.Count); Assert.All(organization.Usage, usage => Assert.Equal(-1, usage.Limit)); } @@ -369,8 +131,8 @@ public void EnsureUsage_UnsetLimit_ReplacesItWithKnownLimit() MaxEventsPerMonth = 15_000, Usage = [ - new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 }, - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + 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 } ] }; @@ -378,30 +140,6 @@ public void EnsureUsage_UnsetLimit_ReplacesItWithKnownLimit() organization.EnsureUsage(_timeProvider); // Assert - Assert.Equal(15_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void GetUsage_ExistingUsage_PreservesKnownLimit() - { - // Arrange - _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); - var existingUsage = new UsageInfo - { - Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), - Limit = 15_000 - }; - var organization = new ViewOrganization - { - MaxEventsPerMonth = 75_000, - Usage = [existingUsage] - }; - - // Act - var usage = organization.GetUsage(existingUsage.Date, _timeProvider); - - // Assert - Assert.Same(existingUsage, usage); - Assert.Equal(15_000, usage.Limit); + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); } } From 7fdbf4f70136f1d4b0add27f061e2330903c5dc4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 9 Aug 2026 08:59:23 -0500 Subject: [PATCH 24/26] Fix usage history materialization --- .../Billing/BillingManager.cs | 2 +- .../Extensions/OrganizationExtensions.cs | 14 +- .../Extensions/ProjectExtensions.cs | 12 +- .../Extensions/UsageExtensions.cs | 33 ++- .../Models/Organization/ViewOrganization.cs | 34 +-- .../Models/Project/ViewProject.cs | 33 +-- .../Billing/BillingManagerTests.cs | 220 +++++------------- .../Extensions/UsageExtensionsTests.cs | 31 +++ .../Extensions/ViewProjectExtensionsTests.cs | 25 ++ 9 files changed, 162 insertions(+), 242 deletions(-) create mode 100644 tests/Exceptionless.Tests/Extensions/UsageExtensionsTests.cs diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 142975834b..f8c60cd31a 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -150,7 +150,7 @@ private void CaptureOutgoingUsageLimit(Organization organization, BillingPlan pl return; } - var previousUsage = organization.Usage.GetUsage(previousMonthUtc, organization.MaxEventsPerMonth); + var previousUsage = organization.Usage.GetOrAddMonthlyUsage(previousMonthUtc, organization.MaxEventsPerMonth); if (previousUsage.Limit == 0) { previousUsage.Limit = organization.MaxEventsPerMonth; diff --git a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs index 84b949f3fc..0abcfd98cd 100644 --- a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs +++ b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs @@ -145,18 +145,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 31ebc3133a..84f070c5d2 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -5,7 +5,7 @@ namespace Exceptionless.Core.Extensions; public static class UsageExtensions { - public static UsageInfo GetUsage(this ICollection usages, DateTime dateUtc, int limit) + 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); @@ -24,6 +24,37 @@ public static UsageInfo GetUsage(this ICollection usages, DateTime da 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/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 0cdb147c47..b97a2b3bd4 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -79,34 +79,12 @@ public static void EnsureUsage(this ViewOrganization organization, TimeProvider var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); var startDateUtc = endDateUtc.SubtractMonths(11); var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); - if (organizationCreatedMonthUtc > startDateUtc) - { - startDateUtc = organizationCreatedMonthUtc; - } + startDateUtc = organizationCreatedMonthUtc > startDateUtc ? organizationCreatedMonthUtc : startDateUtc; - var knownUsages = organization.Usage - .Where(u => u.Limit != 0) - .OrderBy(u => u.Date) - .ToList(); - int limit = knownUsages - .LastOrDefault(u => u.Date <= startDateUtc)?.Limit - ?? knownUsages.FirstOrDefault()?.Limit - ?? organization.GetMaxEventsPerMonthWithBonus(timeProvider); - - while (startDateUtc <= endDateUtc) - { - var usage = organization.Usage.GetUsage(startDateUtc, limit); - if (usage.Limit == 0) - { - usage.Limit = limit; - } - else - { - limit = usage.Limit; - } - - startDateUtc = startDateUtc.AddMonths(1).StartOfMonth(); - } + organization.Usage = organization.Usage.MaterializeMonthlyUsage( + startDateUtc, + endDateUtc, + organization.GetMaxEventsPerMonthWithBonus(timeProvider)); } public static UsageInfo GetCurrentUsage(this ViewOrganization organization, TimeProvider timeProvider) @@ -116,7 +94,7 @@ public static UsageInfo GetCurrentUsage(this ViewOrganization organization, Time public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, TimeProvider timeProvider) { - return organization.Usage.GetUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); + 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 34b7539f2c..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; @@ -53,21 +54,12 @@ public static void EnsureUsage(this ViewProject project, int limit, DateTime org { var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); var startDateUtc = endDateUtc.SubtractYears(1); - var createdMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth(); - if (project.CreatedUtc > organizationCreatedUtc) - { - createdMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth(); - } - if (createdMonthUtc > startDateUtc) - { - startDateUtc = createdMonthUtc; - } + var organizationCreatedMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth(); + var projectCreatedMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth(); + var createdMonthUtc = projectCreatedMonthUtc > organizationCreatedMonthUtc ? projectCreatedMonthUtc : organizationCreatedMonthUtc; + startDateUtc = createdMonthUtc > startDateUtc ? createdMonthUtc : startDateUtc; - while (startDateUtc < endDateUtc) - { - project.GetUsage(startDateUtc, limit); - startDateUtc = startDateUtc.AddMonths(1).StartOfMonth(); - } + project.Usage = project.Usage.MaterializeMonthlyUsage(startDateUtc, endDateUtc.AddMonths(-1), limit); } public static UsageInfo GetCurrentUsage(this ViewProject project, int limit, TimeProvider timeProvider) @@ -77,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/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 8e4c7162b1..bc412f7faa 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -1,83 +1,38 @@ using Exceptionless.Core.Billing; using Exceptionless.Core.Models; +using Exceptionless.DateTimeExtensions; using Xunit; namespace Exceptionless.Tests.Billing; public class BillingManagerTests : TestWithServices { - public BillingManagerTests(ITestOutputHelper output) : base(output) { } - - [Fact] - public void ApplyBillingPlan_BonusWithoutUsage_CreatesPreviousPlanAnchorWithoutBonus() - { - // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.SmallPlan.Id, - MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, - BonusEventsPerMonth = 5_000, - BonusExpiration = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) - }; - - // Act - billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + 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); - // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, - organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } + public BillingManagerTests(ITestOutputHelper output) : base(output) { } - [Fact] - public void ApplyBillingPlan_ExistingBonus_PreservesPreviousPlanAnchorWithBonus() + [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(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + 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 = 5_000, - BonusExpiration = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), - Usage = - [ - new UsageInfo - { - Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), - Limit = plans.SmallPlan.MaxEventsPerMonth + 5_000 - } - ] - }; - - // Act - billingManager.ApplyBillingPlan(organization, plans.MediumPlan); - - // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, - organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor() - { - // Arrange - var billingManager = GetService(); - var plans = GetService(); - var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); - TimeProvider.SetUtcNow(utcNow); - 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); @@ -85,121 +40,54 @@ public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor( // 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(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() - { - // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.SmallPlan.Id, - MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, - Usage = - [ - new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } - ] - }; - - // Act - billingManager.ApplyBillingPlan(organization, plans.MediumPlan); - - // Assert - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, - organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); - } - - [Fact] - public void ApplyBillingPlan_MissingPreviousLimit_DoesNotInventPreviousPlanHistory() - { - // 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, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.SmallPlan.Id - }; - - // Act - billingManager.ApplyBillingPlan(organization, plans.MediumPlan); - - // Assert - var usage = Assert.Single(organization.Usage); - Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); - Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, usage.Limit); + 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); } - [Fact] - public void ApplyBillingPlan_NewOrganization_DoesNotInventPreviousPlanHistory() - { - // Arrange - var billingManager = GetService(); - var plans = GetService(); - var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); - TimeProvider.SetUtcNow(utcNow); - var organization = new Organization(); - - // Act - billingManager.ApplyBillingPlan(organization, plans.FreePlan); - - // Assert - var usage = Assert.Single(organization.Usage); - Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); - Assert.Equal(plans.FreePlan.MaxEventsPerMonth, usage.Limit); - } - - [Fact] - public void ApplyBillingPlan_OrganizationCreatedThisMonth_DoesNotInventPreviousPlanHistory() - { - // 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, 6, 10, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.FreePlan.Id, - MaxEventsPerMonth = plans.FreePlan.MaxEventsPerMonth - }; - - // Act - billingManager.ApplyBillingPlan(organization, plans.SmallPlan); - - // Assert - var usage = Assert.Single(organization.Usage); - Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); - } - - [Fact] - public void ApplyBillingPlan_UnchangedPlan_DoesNotCreatePreviousPlanAnchor() + [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(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); - var organization = new Organization + TimeProvider.SetUtcNow(PlanChangeUtc); + var (organization, targetPlan) = scenario switch { - CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), - PlanId = plans.SmallPlan.Id.ToUpperInvariant(), - MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + 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, plans.SmallPlan); + billingManager.ApplyBillingPlan(organization, targetPlan); // Assert var usage = Assert.Single(organization.Usage); - Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); - Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + Assert.Equal(PlanChangeUtc.StartOfMonth(), usage.Date); + Assert.Equal(targetPlan.MaxEventsPerMonth, usage.Limit); } [Fact] @@ -313,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/ViewProjectExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs index 62978e5e00..f3067df105 100644 --- a/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs @@ -1,3 +1,4 @@ +using Exceptionless.Core.Models; using Exceptionless.Tests.Utility; using Exceptionless.Web.Models; using Xunit; @@ -8,6 +9,30 @@ 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() { From c0080fc60c3fa30b059400ab6732c39d51f059a9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:38:16 -0500 Subject: [PATCH 25/26] Preserve usage anchors during plan updates --- .../Billing/BillingManager.cs | 4 +- .../Services/UsageService.cs | 6 ++- .../Billing/BillingManagerTests.cs | 26 +++++++++++ .../Services/UsageServiceTests.cs | 43 +++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index f8c60cd31a..8ce52be1bb 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -136,8 +136,10 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? private void CaptureOutgoingUsageLimit(Organization organization, BillingPlan plan, DateTime utcNow) { + bool isUnchangedPlan = String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) + && organization.MaxEventsPerMonth == plan.MaxEventsPerMonth; if (String.IsNullOrEmpty(organization.PlanId) - || String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) + || isUnchangedPlan || organization.MaxEventsPerMonth == 0) { return; diff --git a/src/Exceptionless.Core/Services/UsageService.cs b/src/Exceptionless.Core/Services/UsageService.cs index 7d1fa31ab8..0b4272c321 100644 --- a/src/Exceptionless.Core/Services/UsageService.cs +++ b/src/Exceptionless.Core/Services/UsageService.cs @@ -151,7 +151,8 @@ long GetAssistantValue(string metric) organization.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime; bool hasEventUsage = hasIngestion || (bucketDeleted?.Value ?? 0) > 0; - int bucketLimit = GetBucketEventLimit(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), bucketUtc); + int currentMonthlyLimit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider); + int bucketLimit = GetBucketEventLimit(currentMonthlyLimit, bucketUtc); bool hourlyThrottleCleared = hourlyThrottleTransition is { HasValue: true } transition ? transition.Value : bucketLimit >= 0 && bucketTotal is { HasValue: true } total && total.Value >= bucketLimit; @@ -162,7 +163,8 @@ long GetAssistantValue(string metric) if (hasEventUsage) { var usage = organization.GetUsage(bucketUtc, _timeProvider); - usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider); + if (usage.Limit == 0 || bucketUtc.StartOfMonth() == utcNow.StartOfMonth()) + usage.Limit = currentMonthlyLimit; usage.Total += bucketTotal?.Value ?? 0; usage.Blocked += bucketBlocked?.Value ?? 0; usage.Discarded += bucketDiscarded?.Value ?? 0; diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index bc412f7faa..524541d795 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -1,5 +1,6 @@ using Exceptionless.Core.Billing; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Billing; using Exceptionless.DateTimeExtensions; using Xunit; @@ -112,6 +113,31 @@ public void ApplyBillingPlan_UnchangedPlan_UpdatesBillingChangeDate() Assert.Equal(TimeProvider.GetUtcNow().UtcDateTime, organization.BillingChangeDate); } + [Fact] + public void ApplyBillingPlan_SamePlanWithChangedLimit_CreatesOutgoingAnchor() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(PlanChangeUtc); + var organization = new Organization + { + CreatedUtc = PlanChangeUtc.AddMonths(-2), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + }; + BillingPlan updatedPlan = plans.SmallPlan with { MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth * 2 }; + + // Act + billingManager.ApplyBillingPlan(organization, updatedPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, + organization.Usage.Single(usage => usage.Date == PreviousMonthUtc).Limit); + Assert.Equal(updatedPlan.MaxEventsPerMonth, + organization.Usage.Single(usage => usage.Date == PlanChangeUtc.StartOfMonth()).Limit); + } + [Fact] public void GetBillingPlan() { diff --git a/tests/Exceptionless.Tests/Services/UsageServiceTests.cs b/tests/Exceptionless.Tests/Services/UsageServiceTests.cs index c7e0886cc0..ecb516847c 100644 --- a/tests/Exceptionless.Tests/Services/UsageServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/UsageServiceTests.cs @@ -520,6 +520,49 @@ public async Task SavePendingUsageAsync_WhenMonthRollsOver_DoesNotPublishOrganiz } } + [Fact] + public async Task SavePendingUsageAsync_PriorMonthUsageAfterPlanChange_PreservesHistoricalLimit() + { + // Arrange + var mayUsageBucketUtc = new DateTime(2015, 5, 31, 23, 55, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(mayUsageBucketUtc); + var organization = await _organizationRepository.AddAsync(new Organization + { + Name = "Test", + CreatedUtc = mayUsageBucketUtc.AddMonths(-2), + PlanId = _plans.SmallPlan.Id, + MaxEventsPerMonth = _plans.SmallPlan.MaxEventsPerMonth, + StripeCustomerId = "cus_test", + CardLast4 = "4242", + SubscribeDate = mayUsageBucketUtc.AddMonths(-2), + BillingChangedByUserId = "507f1f77bcf86cd799439011" + }, o => o.ImmediateConsistency().Cache()); + var project = await _projectRepository.AddAsync(new Project + { + Name = "Test", + OrganizationId = organization.Id, + NextSummaryEndOfDayTicks = TimeProvider.GetUtcNow().UtcDateTime.Ticks + }, o => o.ImmediateConsistency().Cache()); + await _usageService.IncrementTotalAsync(organization.Id, project.Id); + + TimeProvider.SetUtcNow(new DateTime(2015, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + GetService().ApplyBillingPlan(organization, _plans.MediumPlan); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + + // Act + TimeProvider.Advance(TimeSpan.FromMinutes(10)); + await _usageService.SavePendingUsageAsync(); + + // Assert + organization = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(organization); + var mayUsage = organization.Usage.Single(usage => usage.Date == mayUsageBucketUtc.StartOfMonth()); + Assert.Equal(_plans.SmallPlan.MaxEventsPerMonth, mayUsage.Limit); + Assert.Equal(1, mayUsage.Total); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, + organization.Usage.Single(usage => usage.Date == TimeProvider.GetUtcNow().UtcDateTime.StartOfMonth()).Limit); + } + [Fact] public async Task CanGetEventsLeft() { From 531ab34bc662e2ec7257846e304988de02dd6798 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 15 Sep 2026 19:30:20 -0500 Subject: [PATCH 26/26] Preserve project limits when flushing historical usage --- .../Services/UsageService.cs | 16 +++++++++--- .../Services/UsageServiceTests.cs | 25 ++++++++++++++++--- tests/http/organizations.http | 1 + tests/http/projects.http | 1 + 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Core/Services/UsageService.cs b/src/Exceptionless.Core/Services/UsageService.cs index 0b4272c321..d4a61c4381 100644 --- a/src/Exceptionless.Core/Services/UsageService.cs +++ b/src/Exceptionless.Core/Services/UsageService.cs @@ -330,11 +330,19 @@ private async Task SavePendingProjectUsageAsync(DateTime utcNow) if (hasIngestion) project.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime; - (string OrganizationId, Organization? Organization) context = (OrganizationId: project.OrganizationId, Organization: null); - int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(context); - var usage = project.GetUsage(bucketUtc); - usage.Limit = maxEventsPerMonth; + bool isCurrentMonth = bucketUtc.StartOfMonth() == utcNow.StartOfMonth(); + if (usage.Limit == 0 || isCurrentMonth) + { + (string OrganizationId, Organization? Organization) context = (project.OrganizationId, null); + if (!isCurrentMonth) + context.Organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + + int? historicalLimit = context.Organization?.Usage + .FirstOrDefault(monthlyUsage => monthlyUsage.Date.Year == bucketUtc.Year + && monthlyUsage.Date.Month == bucketUtc.Month && monthlyUsage.Limit != 0)?.Limit; + usage.Limit = historicalLimit ?? await GetMaxEventsPerMonthAsync(context); + } usage.Total += bucketTotal?.Value ?? 0; usage.Blocked += bucketBlocked?.Value ?? 0; usage.Discarded += bucketDiscarded?.Value ?? 0; diff --git a/tests/Exceptionless.Tests/Services/UsageServiceTests.cs b/tests/Exceptionless.Tests/Services/UsageServiceTests.cs index ecb516847c..5a9536dead 100644 --- a/tests/Exceptionless.Tests/Services/UsageServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/UsageServiceTests.cs @@ -520,8 +520,12 @@ public async Task SavePendingUsageAsync_WhenMonthRollsOver_DoesNotPublishOrganiz } } - [Fact] - public async Task SavePendingUsageAsync_PriorMonthUsageAfterPlanChange_PreservesHistoricalLimit() + [Theory] + [InlineData(null, 15_000, 1)] + [InlineData(0, 15_000, 3)] + [InlineData(20_000, 20_000, 3)] + [InlineData(-1, -1, 3)] + public async Task SavePendingUsageAsync_PriorMonthUsageAfterPlanChange_PreservesHistoricalLimit(int? previousProjectLimit, int expectedProjectLimit, int expectedProjectTotal) { // Arrange var mayUsageBucketUtc = new DateTime(2015, 5, 31, 23, 55, 0, DateTimeKind.Utc); @@ -541,15 +545,19 @@ public async Task SavePendingUsageAsync_PriorMonthUsageAfterPlanChange_Preserves { Name = "Test", OrganizationId = organization.Id, - NextSummaryEndOfDayTicks = TimeProvider.GetUtcNow().UtcDateTime.Ticks + NextSummaryEndOfDayTicks = TimeProvider.GetUtcNow().UtcDateTime.Ticks, + Usage = previousProjectLimit.HasValue + ? [new UsageInfo { Date = mayUsageBucketUtc.StartOfMonth(), Limit = previousProjectLimit.Value, Total = 2 }] + : [] }, o => o.ImmediateConsistency().Cache()); await _usageService.IncrementTotalAsync(organization.Id, project.Id); TimeProvider.SetUtcNow(new DateTime(2015, 6, 1, 0, 0, 0, DateTimeKind.Utc)); GetService().ApplyBillingPlan(organization, _plans.MediumPlan); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); // Act + await _usageService.IncrementTotalAsync(organization.Id, project.Id); TimeProvider.Advance(TimeSpan.FromMinutes(10)); await _usageService.SavePendingUsageAsync(); @@ -561,6 +569,15 @@ public async Task SavePendingUsageAsync_PriorMonthUsageAfterPlanChange_Preserves Assert.Equal(1, mayUsage.Total); Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(usage => usage.Date == TimeProvider.GetUtcNow().UtcDateTime.StartOfMonth()).Limit); + + project = await _projectRepository.GetByIdAsync(project.Id); + Assert.NotNull(project); + var projectMayUsage = project.Usage.Single(usage => usage.Date == mayUsageBucketUtc.StartOfMonth()); + Assert.Equal(expectedProjectLimit, projectMayUsage.Limit); + Assert.Equal(expectedProjectTotal, projectMayUsage.Total); + var currentProjectUsage = project.GetCurrentUsage(TimeProvider); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, currentProjectUsage.Limit); + Assert.Equal(1, currentProjectUsage.Total); } [Fact] diff --git a/tests/http/organizations.http b/tests/http/organizations.http index 5784301ada..4879fcedda 100644 --- a/tests/http/organizations.http +++ b/tests/http/organizations.http @@ -27,6 +27,7 @@ GET {{apiUrl}}/organizations?filter=Acme Authorization: Bearer {{token}} ### By Id +# Missing usage months inherit recorded limits, starting at the organization's creation month. GET {{apiUrl}}/organizations/{{organizationId}} Authorization: Bearer {{token}} diff --git a/tests/http/projects.http b/tests/http/projects.http index 87ed0c2740..7fc737eb82 100644 --- a/tests/http/projects.http +++ b/tests/http/projects.http @@ -31,6 +31,7 @@ GET {{apiUrl}}/projects?organizationId={{organizationId}} Authorization: Bearer {{token}} ### Get By Id +# Missing usage months start no earlier than the project or organization creation month. GET {{apiUrl}}/projects/{{projectId}} Authorization: Bearer {{token}}