From 5aa24f489ffc979241fd94b1aa71c467f5cbb3e7 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 08:20:47 -0500 Subject: [PATCH 01/35] Add a 7-day no-card free trial for new organizations A user's first organization now starts on a hidden EX_TRIAL plan with Medium features for 7 days, then moves to the Free plan, which keeps the non-dismissible upgrade banner. Upgrading during the trial uses the existing Stripe flow and bills immediately; Stripe is not involved in the trial itself. - Remove user and project limits from every plan - Require a verified email and a daily limit to send invites - Hourly TrialExpirationJob sends a reminder email and ends trials - Exie on trials needs a verified email, a $1 monthly cap, and a shared daily cost cap across all trial organizations - Trial banner, post-trial Free banner, and trial-aware plan dialog - Billing:* settings, including TrialDays (0 disables trials) Co-Authored-By: Claude Opus 5.5 --- build/EmailTemplatePreviews.cs | 5 + k8s/exceptionless/templates/jobs.yaml | 57 ++++ .../Billing/BillingManager.cs | 70 ++--- .../Billing/BillingPlans.cs | 57 +++- src/Exceptionless.Core/Bootstrapper.cs | 3 +- .../Configuration/AppOptions.cs | 4 +- .../Configuration/BillingOptions.cs | 42 +++ .../Jobs/TrialExpirationJob.cs | 128 ++++++++ src/Exceptionless.Core/Mail/IMailer.cs | 3 +- src/Exceptionless.Core/Mail/Mailer.cs | 22 +- .../Models/Billing/BillingPlan.cs | 10 + .../Models/Billing/BillingPlanStats.cs | 3 + src/Exceptionless.Core/Models/Organization.cs | 13 +- src/Exceptionless.Core/Models/User.cs | 6 + .../Repositories/OrganizationRepository.cs | 17 +- .../Models/EmailTemplate.cs | 9 +- .../RazorEmailTemplateRenderer.cs | 3 +- .../Templates/OrganizationTrialEnding.razor | 12 + src/Exceptionless.Job/JobRunnerOptions.cs | 7 +- src/Exceptionless.Job/Program.cs | 7 +- .../Api/Endpoints/AssistantEndpoints.cs | 5 +- .../Api/Handlers/AdminHandler.cs | 9 +- .../Api/Handlers/OrganizationHandler.cs | 40 ++- .../Api/Handlers/ProjectHandler.cs | 7 +- .../Assistant/AssistantAccessService.cs | 15 +- .../Assistant/AssistantModels.cs | 5 +- .../Assistant/AssistantService.cs | 4 +- .../Assistant/AssistantUsageService.cs | 57 +++- .../features/assistant/access-state.test.ts | 8 + .../lib/features/assistant/access-state.ts | 4 + .../assistant-upgrade-required.svelte | 7 + .../assistant-upgrade-required.svelte.test.ts | 14 + .../src/lib/features/assistant/models.ts | 3 +- .../components/change-plan-dialog.svelte | 55 ++-- .../src/lib/features/billing/constants.ts | 4 + .../src/lib/features/billing/index.ts | 2 +- .../src/lib/features/billing/trial.test.ts | 17 ++ .../src/lib/features/billing/trial.ts | 18 ++ .../free-plan-notification.svelte | 16 +- .../components/notifications/index.ts | 4 +- .../organization-notifications.stories.svelte | 12 +- .../notifications/trial-notification.svelte | 26 ++ .../organization-notifications.svelte | 29 +- .../ClientApp/src/lib/generated/api.ts | 8 + .../ClientApp/src/lib/generated/schemas.ts | 3 + .../Models/Organization/ViewOrganization.cs | 1 + .../Exceptionless.Tests/Api/Data/openapi.json | 22 +- .../Api/Endpoints/ContactEndpointTests.cs | 7 +- .../Endpoints/OrganizationEndpointTests.cs | 277 ++++++++++++++---- .../Api/Handlers/ContactHandlerTests.cs | 7 +- .../Assistant/AssistantAccessServiceTests.cs | 34 ++- .../Assistant/AssistantUsageServiceTests.cs | 33 ++- .../Jobs/TrialExpirationJobTests.cs | 96 ++++++ .../Mail/CountingMailer.cs | 19 +- tests/Exceptionless.Tests/Mail/MailerTests.cs | 20 +- tests/Exceptionless.Tests/Mail/NullMailer.cs | 5 + 56 files changed, 1177 insertions(+), 194 deletions(-) create mode 100644 src/Exceptionless.Core/Configuration/BillingOptions.cs create mode 100644 src/Exceptionless.Core/Jobs/TrialExpirationJob.cs create mode 100644 src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte create mode 100644 tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs diff --git a/build/EmailTemplatePreviews.cs b/build/EmailTemplatePreviews.cs index 1e88ed5481..52957bffa7 100644 --- a/build/EmailTemplatePreviews.cs +++ b/build/EmailTemplatePreviews.cs @@ -201,6 +201,11 @@ EmailLink[] GetActionLinks(string stackId) => "[Engineering] Payment failed! Update billing information to avoid service interruption!", "Engineering", $"{appUrl}/organization/organization-1/billing")), + new("organization-trial-ending", "Trial ending", new OrganizationTrialEndingEmail( + "[Engineering] Your free trial ends soon", + "Engineering", + "Friday, October 30, 2026", + $"{appUrl}/organization/organization-1/upgrade")), new("project-daily-summary", "Daily summary", new ProjectDailySummaryEmail( "[Storefront API] Summary for August 24, 2026", "Storefront API", diff --git a/k8s/exceptionless/templates/jobs.yaml b/k8s/exceptionless/templates/jobs.yaml index 3a71d1a8ae..af582bf1a8 100644 --- a/k8s/exceptionless/templates/jobs.yaml +++ b/k8s/exceptionless/templates/jobs.yaml @@ -789,6 +789,63 @@ spec: {{- include "exceptionless.otel-env" . | indent 16 }} restartPolicy: OnFailure +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ template "exceptionless.fullname" . }}-jobs-trial-expiration + labels: + app: {{ template "exceptionless.name" . }} + component: {{ template "exceptionless.fullname" . }}-jobs-trial-expiration + tier: {{ template "exceptionless.fullname" . }}-job + chart: {{ template "exceptionless.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + schedule: "15 * * * *" # hourly + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + containers: + - name: {{ template "exceptionless.name" . }}-jobs-trial-expiration + image: "{{ .Values.jobs.image.repository }}:{{ .Values.version }}" + imagePullPolicy: {{ .Values.jobs.image.pullPolicy }} + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + memory: 75Mi + cpu: 50m + limits: + memory: 250Mi + cpu: 400m + args: [TrialExpiration] + envFrom: + - configMapRef: + name: {{ template "exceptionless.fullname" . }}-config + env: + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP + - name: K8S_POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: K8S_POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid +{{- include "exceptionless.otel-env" . | indent 16 }} + restartPolicy: OnFailure + --- apiVersion: apps/v1 kind: Deployment diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 3ebc2b3781..cfa5fbffbb 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; @@ -11,18 +12,16 @@ public class BillingManager private static readonly TimeSpan BillingLockDuration = TimeSpan.FromMinutes(5); private readonly ILockProvider _lockProvider; private readonly IOrganizationRepository _organizationRepository; - private readonly IProjectRepository _projectRepository; - private readonly IUserRepository _userRepository; private readonly BillingPlans _plans; + private readonly BillingOptions _billingOptions; private readonly TimeProvider _timeProvider; - public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IUserRepository userRepository, BillingPlans plans, TimeProvider timeProvider) + public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, BillingPlans plans, AppOptions appOptions, TimeProvider timeProvider) { _lockProvider = lockProvider; _organizationRepository = organizationRepository; - _projectRepository = projectRepository; - _userRepository = userRepository; _plans = plans; + _billingOptions = appOptions.BillingOptions; _timeProvider = timeProvider; } @@ -37,30 +36,45 @@ public async Task CanAddOrganizationAsync(User? user) if (user is null) return false; - var organizations = (await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray())).Where(o => o.PlanId == _plans.FreePlan.Id); - return !organizations.Any(); + var organizations = await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray()); + return !organizations.Any(o => _plans.IsFreeOrTrialPlan(o.PlanId)); } - public async Task CanAddUserAsync(Organization organization) + /// + /// A user gets one no-card trial, and only for the first organization they create. + /// + public bool CanStartTrial(User user) { - if (String.IsNullOrWhiteSpace(organization?.Id)) - return false; + ArgumentNullException.ThrowIfNull(user); + return _billingOptions.TrialDays > 0 && user.TrialStartedUtc is null && user.OrganizationIds.Count == 0; + } + + public void StartTrial(Organization organization, User user) + { + ArgumentNullException.ThrowIfNull(organization); + ArgumentNullException.ThrowIfNull(user); - long numberOfUsers = (await _userRepository.GetByOrganizationIdAsync(organization.Id)).Total + organization.Invites.Count; - return organization.MaxUsers <= -1 || numberOfUsers < organization.MaxUsers; + ApplyTrialPlan(organization, user); + user.TrialStartedUtc = _timeProvider.GetUtcNow().UtcDateTime; } - public async Task CanAddProjectAsync(Project project) + /// + /// Applies the trial plan and starts a full-length trial. Also used by admins to extend a trial. + /// + public void ApplyTrialPlan(Organization organization, User? user = null) { - if (String.IsNullOrWhiteSpace(project?.OrganizationId)) - return false; + ArgumentNullException.ThrowIfNull(organization); - var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId); - if (organization is null) - return false; + int trialDays = _billingOptions.TrialDays > 0 ? _billingOptions.TrialDays : 7; + ApplyBillingPlan(organization, _plans.TrialPlan, user); + organization.TrialEndsUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(trialDays); + organization.TrialReminderSentUtc = null; + } - long projectCount = await _projectRepository.GetCountByOrganizationIdAsync(project.OrganizationId); - return organization.MaxProjects == -1 || projectCount < organization.MaxProjects; + public void EndTrial(Organization organization) + { + ArgumentNullException.ThrowIfNull(organization); + ApplyBillingPlan(organization, _plans.FreePlan); } public async Task HasPremiumFeaturesAsync(string organizationId) @@ -77,18 +91,8 @@ public async Task CanDownGradeAsync(Organization organization, if (String.IsNullOrWhiteSpace(organization?.Id)) return ChangePlanResult.FailWithMessage("Invalid Organization"); - long currentNumberOfUsers = (await _userRepository.GetByOrganizationIdAsync(organization.Id)).Total + organization.Invites.Count; - int maxUsers = plan.MaxUsers != -1 ? plan.MaxUsers : Int32.MaxValue; - if (currentNumberOfUsers > maxUsers) - return ChangePlanResult.FailWithMessage($"Please remove {currentNumberOfUsers - maxUsers} user{((currentNumberOfUsers - maxUsers) > 0 ? "s" : String.Empty)} and try again."); - - int maxProjects = plan.MaxProjects != -1 ? plan.MaxProjects : Int32.MaxValue; - long projectCount = await _projectRepository.GetCountByOrganizationIdAsync(organization.Id); - if (projectCount > maxProjects) - return ChangePlanResult.FailWithMessage($"Please remove {projectCount - maxProjects} project{((projectCount - maxProjects) > 0 ? "s" : String.Empty)} and try again."); - - // Ensure the user can't be apart of more than one free plan. - if (String.Equals(plan.Id, _plans.FreePlan.Id) && user is not null && (await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray())).Any(o => String.Equals(o.PlanId, _plans.FreePlan.Id))) + // Ensure the user can't be apart of more than one free or trial plan. + if (_plans.IsFreePlan(plan.Id) && user is not null && (await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray())).Any(o => !String.Equals(o.Id, organization.Id) && _plans.IsFreeOrTrialPlan(o.PlanId))) return ChangePlanResult.FailWithMessage("You already have one free account. You are not allowed to create more than one free account."); return new ChangePlanResult { Success = true }; diff --git a/src/Exceptionless.Core/Billing/BillingPlans.cs b/src/Exceptionless.Core/Billing/BillingPlans.cs index 648a2745e0..6a4a436ba1 100644 --- a/src/Exceptionless.Core/Billing/BillingPlans.cs +++ b/src/Exceptionless.Core/Billing/BillingPlans.cs @@ -6,6 +6,15 @@ public class BillingPlans { public BillingPlans(AppOptions options) { + var trialAssistantOptions = new AssistantPlanOptions + { + MaximumConcurrentTurns = 1, + MaximumTurnsPerMinute = 5, + MaximumMonthlyTokens = 5_000_000, + MaximumMonthlyCostUsd = 1m, + SharedDailyCostLimitKey = "trial", + MaximumSharedDailyCostUsd = options.BillingOptions?.TrialAssistantDailyCostLimitUsd ?? 0 + }; var mediumAssistantOptions = new AssistantPlanOptions { MaximumConcurrentTurns = 2, @@ -48,21 +57,36 @@ public BillingPlans(AppOptions options) Name = "Free", Description = "Free", Price = 0, - MaxProjects = 1, - MaxUsers = 1, + MaxProjects = -1, + MaxUsers = -1, RetentionDays = 3, MaxEventsPerMonth = 3000, HasPremiumFeatures = false }; + TrialPlan = new BillingPlan + { + Id = "EX_TRIAL", + Name = "Trial", + Description = "Free Trial", + IsHidden = true, + Price = 0, + MaxProjects = -1, + MaxUsers = -1, + RetentionDays = 90, + MaxEventsPerMonth = 75000, + HasPremiumFeatures = true, + Assistant = trialAssistantOptions + }; + SmallPlan = new BillingPlan { Id = "EX_SMALL", Name = "Small", Description = "Small ($15/month)", Price = 15, - MaxProjects = 5, - MaxUsers = 10, + MaxProjects = -1, + MaxUsers = -1, RetentionDays = 30, MaxEventsPerMonth = 15000, HasPremiumFeatures = true @@ -74,8 +98,8 @@ public BillingPlans(AppOptions options) Name = "Small (Yearly)", Description = "Small Yearly ($165/year - Save $15)", Price = 165, - MaxProjects = 5, - MaxUsers = 10, + MaxProjects = -1, + MaxUsers = -1, RetentionDays = 30, MaxEventsPerMonth = 15000, HasPremiumFeatures = true @@ -87,8 +111,8 @@ public BillingPlans(AppOptions options) Name = "Medium", Description = "Medium ($49/month)", Price = 49, - MaxProjects = 15, - MaxUsers = 25, + MaxProjects = -1, + MaxUsers = -1, RetentionDays = 90, MaxEventsPerMonth = 75000, HasPremiumFeatures = true, @@ -101,8 +125,8 @@ public BillingPlans(AppOptions options) Name = "Medium (Yearly)", Description = "Medium Yearly ($539/year - Save $49)", Price = 539, - MaxProjects = 15, - MaxUsers = 25, + MaxProjects = -1, + MaxUsers = -1, RetentionDays = 90, MaxEventsPerMonth = 75000, HasPremiumFeatures = true, @@ -208,11 +232,16 @@ public BillingPlans(AppOptions options) Assistant = unlimitedAssistantOptions }; - Plans = new List { FreePlan, SmallYearlyPlan, MediumYearlyPlan, LargeYearlyPlan, ExtraLargeYearlyPlan, EnterpriseYearlyPlan, SmallPlan, MediumPlan, LargePlan, ExtraLargePlan, EnterprisePlan, UnlimitedPlan }; + Plans = new List { FreePlan, TrialPlan, SmallYearlyPlan, MediumYearlyPlan, LargeYearlyPlan, ExtraLargeYearlyPlan, EnterpriseYearlyPlan, SmallPlan, MediumPlan, LargePlan, ExtraLargePlan, EnterprisePlan, UnlimitedPlan }; } public BillingPlan FreePlan { get; } + /// + /// Hidden no-card trial with Medium plan features. Trial organizations move to the free plan when the trial ends. + /// + public BillingPlan TrialPlan { get; } + public BillingPlan SmallPlan { get; } public BillingPlan SmallYearlyPlan { get; } @@ -237,6 +266,12 @@ public BillingPlans(AppOptions options) public List Plans { get; } + public bool IsFreePlan(string? planId) => String.Equals(planId, FreePlan.Id, StringComparison.OrdinalIgnoreCase); + + public bool IsTrialPlan(string? planId) => String.Equals(planId, TrialPlan.Id, StringComparison.OrdinalIgnoreCase); + + public bool IsFreeOrTrialPlan(string? planId) => IsFreePlan(planId) || IsTrialPlan(planId); + public BillingPlan? GetPlan(string? planId) => planId is null ? null : Plans.FirstOrDefault(plan => String.Equals(plan.Id, planId, StringComparison.OrdinalIgnoreCase)); diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 0d45dc5823..f7580b3b50 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Sockets; using System.Text.Json; using Elastic.Clients.Elasticsearch; @@ -523,6 +523,7 @@ public static void AddHostedJobs(IServiceCollection services, ILoggerFactory log services.AddDistributedCronJob("45 */8 * * *"); services.AddDistributedCronJob(Cron.Daily(1)); services.AddDistributedCronJob("10 */2 * * *"); + services.AddDistributedCronJob("15 * * * *"); var logger = loggerFactory.CreateLogger(); logger.LogWarning("Jobs running in process"); diff --git a/src/Exceptionless.Core/Configuration/AppOptions.cs b/src/Exceptionless.Core/Configuration/AppOptions.cs index a0528ec823..1b17b27dad 100644 --- a/src/Exceptionless.Core/Configuration/AppOptions.cs +++ b/src/Exceptionless.Core/Configuration/AppOptions.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Text.Json.Serialization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; @@ -82,6 +82,7 @@ public class AppOptions public IntercomOptions IntercomOptions { get; internal set; } = null!; public SlackOptions SlackOptions { get; internal set; } = null!; public StripeOptions StripeOptions { get; internal set; } = null!; + public BillingOptions BillingOptions { get; internal set; } = null!; public AuthOptions AuthOptions { get; internal set; } = null!; public OAuthServerOptions OAuthServerOptions { get; internal set; } = null!; public SourceMapOptions SourceMapOptions { get; internal set; } = null!; @@ -139,6 +140,7 @@ public static AppOptions ReadFromConfiguration(IConfiguration config) options.IntercomOptions = IntercomOptions.ReadFromConfiguration(config); options.SlackOptions = SlackOptions.ReadFromConfiguration(config); options.StripeOptions = StripeOptions.ReadFromConfiguration(config); + options.BillingOptions = BillingOptions.ReadFromConfiguration(config); options.AuthOptions = AuthOptions.ReadFromConfiguration(config); options.OAuthServerOptions = OAuthServerOptions.ReadFromConfiguration(config); options.SourceMapOptions = SourceMapOptions.ReadFromConfiguration(config); diff --git a/src/Exceptionless.Core/Configuration/BillingOptions.cs b/src/Exceptionless.Core/Configuration/BillingOptions.cs new file mode 100644 index 0000000000..ce00e2e6ce --- /dev/null +++ b/src/Exceptionless.Core/Configuration/BillingOptions.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; + +namespace Exceptionless.Core.Configuration; + +public class BillingOptions +{ + /// + /// Length of the no-card trial a user's first organization receives. Zero disables trials. + /// + public int TrialDays { get; internal set; } = 7; + + /// + /// Days before a trial ends to send the trial ending reminder email. + /// + public int TrialReminderDays { get; internal set; } = 2; + + /// + /// Combined daily Exie cost limit across all trial organizations. Zero or less disables the limit. + /// + public decimal TrialAssistantDailyCostLimitUsd { get; internal set; } = 25m; + + /// + /// Maximum organization invites a user can send per UTC day from free or trial organizations. + /// + public int FreeInviteDailyLimit { get; internal set; } = 10; + + /// + /// Maximum organization invites a user can send per UTC day from paid organizations. + /// + public int PaidInviteDailyLimit { get; internal set; } = 50; + + public static BillingOptions ReadFromConfiguration(IConfiguration config) + { + var options = new BillingOptions(); + options.TrialDays = Math.Max(0, config.GetValue("Billing:TrialDays", 7)); + options.TrialReminderDays = Math.Max(0, config.GetValue("Billing:TrialReminderDays", 2)); + options.TrialAssistantDailyCostLimitUsd = config.GetValue("Billing:TrialAssistantDailyCostLimitUsd", 25m); + options.FreeInviteDailyLimit = Math.Max(0, config.GetValue("Billing:FreeInviteDailyLimit", 10)); + options.PaidInviteDailyLimit = Math.Max(0, config.GetValue("Billing:PaidInviteDailyLimit", 50)); + return options; + } +} diff --git a/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs new file mode 100644 index 0000000000..a3e7cc11e2 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs @@ -0,0 +1,128 @@ +using Exceptionless.Core.Billing; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Messaging; +using Foundatio.Repositories; +using Foundatio.Resilience; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs; + +/// +/// Sends the trial ending reminder and moves organizations whose no-card trial has ended to the free plan. +/// +[Job(Description = "Sends trial reminders and moves expired trials to the free plan.", IsContinuous = false)] +public class TrialExpirationJob : JobWithLockBase, IHealthCheck +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; + private readonly BillingManager _billingManager; + private readonly BillingPlans _plans; + private readonly BillingOptions _billingOptions; + private readonly IMailer _mailer; + private readonly IMessagePublisher _messagePublisher; + private readonly ILockProvider _lockProvider; + private DateTime? _lastRun; + + public TrialExpirationJob(IOrganizationRepository organizationRepository, IUserRepository userRepository, + BillingManager billingManager, BillingPlans plans, AppOptions appOptions, IMailer mailer, + IMessagePublisher messagePublisher, ILockProvider lockProvider, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory + ) : base(timeProvider, resiliencePolicyProvider, loggerFactory) + { + _organizationRepository = organizationRepository; + _userRepository = userRepository; + _billingManager = billingManager; + _plans = plans; + _billingOptions = appOptions.BillingOptions; + _mailer = mailer; + _messagePublisher = messagePublisher; + _lockProvider = lockProvider; + } + + protected override Task GetLockAsync(CancellationToken cancellationToken = default) + { + return _lockProvider.TryAcquireAsync(nameof(TrialExpirationJob), TimeSpan.FromMinutes(30), cancellationToken); + } + + protected override async Task RunInternalAsync(JobContext context) + { + _lastRun = _timeProvider.GetUtcNow().UtcDateTime; + + var results = await _organizationRepository.FindAsync(q => q + .FieldEquals(o => o.PlanId, _plans.TrialPlan.Id) + .OnlyIds(), o => o.SearchAfterPaging().PageLimit(100)); + + while (results.Hits.Count > 0 && !context.CancellationToken.IsCancellationRequested) + { + foreach (string organizationId in results.Hits.Where(h => h.Id is not null).Select(h => h.Id!)) + { + try + { + await ProcessOrganizationAsync(organizationId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing trial for organization {OrganizationId}: {Message}", organizationId, ex.Message); + } + } + + if (!await results.NextPageAsync()) + break; + + await context.RenewLockAsync(); + } + + return JobResult.Success; + } + + private async Task ProcessOrganizationAsync(string organizationId) + { + await using var billingLock = await _billingManager.AcquireOrganizationLockAsync(organizationId); + var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache(false)); + if (organization is null || !_plans.IsTrialPlan(organization.PlanId)) + return; + + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + if (organization.TrialEndsUtc is null || organization.TrialEndsUtc <= utcNow) + { + _logger.LogInformation("Trial ended for organization {OrganizationId}, moving to the free plan", organization.Id); + _billingManager.EndTrial(organization); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); + await _messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id }); + return; + } + + if (organization.TrialReminderSentUtc is not null || organization.TrialEndsUtc.Value.AddDays(-_billingOptions.TrialReminderDays) > utcNow) + return; + + // Mark the reminder before sending so a failure can't cause repeated emails. + organization.TrialReminderSentUtc = utcNow; + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); + + var users = await _userRepository.GetByOrganizationIdAsync(organization.Id); + foreach (var user in users.Documents.Where(u => u.IsEmailAddressVerified && u.EmailNotificationsEnabled)) + await _mailer.SendOrganizationTrialEndingAsync(user, organization); + + _logger.LogInformation("Sent trial ending reminder for organization {OrganizationId}", organization.Id); + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_lastRun.HasValue) + return Task.FromResult(HealthCheckResult.Healthy("Job has not been run yet.")); + + if (_timeProvider.GetUtcNow().UtcDateTime.Subtract(_lastRun.Value) > TimeSpan.FromMinutes(65)) + return Task.FromResult(HealthCheckResult.Unhealthy("Job has not run in the last 65 minutes.")); + + return Task.FromResult(HealthCheckResult.Healthy("Job has run in the last 65 minutes.")); + } +} diff --git a/src/Exceptionless.Core/Mail/IMailer.cs b/src/Exceptionless.Core/Mail/IMailer.cs index 6e8905cab5..831eaee9ea 100644 --- a/src/Exceptionless.Core/Mail/IMailer.cs +++ b/src/Exceptionless.Core/Mail/IMailer.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Models; +using Exceptionless.Core.Models; namespace Exceptionless.Core.Mail; @@ -10,6 +10,7 @@ public interface IMailer Task SendOrganizationInviteAsync(User sender, Organization organization, Invite invite); Task SendOrganizationNoticeAsync(User user, Organization organization, bool isOverMonthlyLimit, bool isOverHourlyLimit); Task SendOrganizationPaymentFailedAsync(User owner, Organization organization); + Task SendOrganizationTrialEndingAsync(User user, Organization organization); Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan); Task SendUserEmailVerifyAsync(User user); Task SendUserPasswordResetAsync(User user); diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 85933fb25a..10e57d57b2 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Extensions; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; using Exceptionless.Core.Queues.Models; @@ -248,6 +248,26 @@ await QueueMessageAsync(new MailMessage }, template); } + public async Task SendOrganizationTrialEndingAsync(User user, Organization organization) + { + ArgumentNullException.ThrowIfNull(organization.TrialEndsUtc); + + const string template = "organization-trial-ending"; + string subject = $"[{organization.Name}] Your free trial ends soon"; + var message = new OrganizationTrialEndingEmail( + subject, + organization.Name, + organization.TrialEndsUtc.Value.ToLongDateString(), + _appUrls.OrganizationUpgrade(organization.Id)); + + await QueueMessageAsync(new MailMessage + { + To = user.EmailAddress, + Subject = subject, + Body = await _templateRenderer.RenderAsync(message) + }, template); + } + public async Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan) { const string template = "project-daily-summary"; diff --git a/src/Exceptionless.Core/Models/Billing/BillingPlan.cs b/src/Exceptionless.Core/Models/Billing/BillingPlan.cs index aedc323cb4..5dec093d7d 100644 --- a/src/Exceptionless.Core/Models/Billing/BillingPlan.cs +++ b/src/Exceptionless.Core/Models/Billing/BillingPlan.cs @@ -27,4 +27,14 @@ public sealed record AssistantPlanOptions public int MaximumTurnsPerMinute { get; init; } public long MaximumMonthlyTokens { get; init; } public decimal MaximumMonthlyCostUsd { get; init; } + + /// + /// Groups organizations whose combined daily Exie cost is limited by . + /// + public string? SharedDailyCostLimitKey { get; init; } + + /// + /// Combined daily Exie cost limit for every organization sharing . Zero or less disables the limit. + /// + public decimal MaximumSharedDailyCostUsd { get; init; } } diff --git a/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs b/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs index 61013014f7..8c0d0791d8 100644 --- a/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs +++ b/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs @@ -13,6 +13,9 @@ public record BillingPlanStats public int MonthlyTotalAccounts { get; init; } public int YearlyTotalAccounts { get; init; } public int FreeAccounts { get; init; } + public int TrialAccounts { get; init; } + public int ConvertedTrialAccounts { get; init; } + public int ExpiredTrialAccounts { get; init; } public int PaidAccounts { get; init; } public int FreeloaderAccounts { get; init; } public int SuspendedAccounts { get; init; } diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 4cfa22dac8..4418c05541 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using Exceptionless.Core.Attributes; @@ -77,6 +77,17 @@ public Organization() /// public DateTime? SubscribeDate { get; set; } + /// + /// Date the organization's no-card trial ends. Kept after the trial ends so the app can tell the + /// organization its trial is over. + /// + public DateTime? TrialEndsUtc { get; set; } + + /// + /// Date the trial ending reminder email was sent. + /// + public DateTime? TrialReminderSentUtc { get; set; } + /// /// Date the billing information was last changed. /// diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index b234f57e25..5bf0c80574 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -52,6 +52,12 @@ public record User : IIdentity, IHaveDates, IValidatableObject public bool? AssistantConversationSharingEnabled { get; set; } public bool IsEmailAddressVerified { get; set; } public string? VerifyEmailAddressToken { get; set; } + + /// + /// Date the user started their one no-card trial. + /// + public DateTime? TrialStartedUtc { get; set; } + public DateTime VerifyEmailAddressTokenExpiration { get; set; } /// diff --git a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs index 07c61e0207..80bfefdf69 100644 --- a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs +++ b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Billing; +using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; @@ -71,9 +71,9 @@ public Task> GetByCriteriaAsync(string? criteria, Comm if (paid.HasValue) { if (paid.Value) - query.FieldNotEquals(o => o.PlanId, _plans.FreePlan.Id); + query.FieldNotEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id); else - query.FieldEquals(o => o.PlanId, _plans.FreePlan.Id); + query.FieldEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id); } if (suspended.HasValue) @@ -117,7 +117,7 @@ public Task> GetByCriteriaAsync(string? criteria, Comm public async Task GetBillingPlanStatsAsync() { var results = (await FindAsync(q => q - .Include(o => o.PlanId, o => o.IsSuspended, o => o.BillingPrice, o => o.BillingStatus) + .Include(o => o.PlanId, o => o.IsSuspended, o => o.BillingPrice, o => o.BillingStatus, o => o.TrialEndsUtc) .SortDescending(o => o.PlanId))).Documents; var smallOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.SmallPlan.Id) && o.BillingPrice > 0).ToList(); var mediumOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.MediumPlan.Id) && o.BillingPrice > 0).ToList(); @@ -145,9 +145,12 @@ public async Task GetBillingPlanStatsAsync() YearlyTotal = (monthlyTotalPaid * 12) + yearlyTotalPaid, MonthlyTotalAccounts = smallOrganizations.Count + mediumOrganizations.Count + largeOrganizations.Count, YearlyTotalAccounts = smallYearlyOrganizations.Count + mediumYearlyOrganizations.Count + largeYearlyOrganizations.Count, - FreeAccounts = results.Count(o => String.Equals(o.PlanId, _plans.FreePlan.Id)), - PaidAccounts = results.Count(o => !String.Equals(o.PlanId, _plans.FreePlan.Id) && o.BillingPrice > 0), - FreeloaderAccounts = results.Count(o => !String.Equals(o.PlanId, _plans.FreePlan.Id) && o.BillingPrice <= 0), + FreeAccounts = results.Count(o => _plans.IsFreePlan(o.PlanId)), + TrialAccounts = results.Count(o => _plans.IsTrialPlan(o.PlanId)), + ConvertedTrialAccounts = results.Count(o => o.TrialEndsUtc is not null && !_plans.IsFreeOrTrialPlan(o.PlanId) && o.BillingPrice > 0), + ExpiredTrialAccounts = results.Count(o => o.TrialEndsUtc is not null && _plans.IsFreePlan(o.PlanId)), + PaidAccounts = results.Count(o => !_plans.IsFreeOrTrialPlan(o.PlanId) && o.BillingPrice > 0), + FreeloaderAccounts = results.Count(o => !_plans.IsFreeOrTrialPlan(o.PlanId) && o.BillingPrice <= 0), SuspendedAccounts = results.Count(o => o.IsSuspended), }; } diff --git a/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs b/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs index b0e97c481e..d5e0c73c19 100644 --- a/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs +++ b/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs @@ -1,4 +1,4 @@ -namespace Exceptionless.EmailTemplates.Models; +namespace Exceptionless.EmailTemplates.Models; public abstract record EmailTemplate(string Subject, EmailAction? Action = null, string? Preview = null); @@ -53,6 +53,13 @@ public sealed record OrganizationPaymentFailedEmail( string BillingUrl) : EmailTemplate(Subject, new EmailAction("Update Billing Information", BillingUrl)); +public sealed record OrganizationTrialEndingEmail( + string Subject, + string OrganizationName, + string TrialEndsDate, + string UpgradeUrl) + : EmailTemplate(Subject, new EmailAction("Upgrade Plan", UpgradeUrl)); + public sealed record ProjectDailySummaryEmail( string Subject, string ProjectName, diff --git a/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs b/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs index 17312bde78..7d5233e960 100644 --- a/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs +++ b/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs @@ -1,4 +1,4 @@ -using Exceptionless.EmailTemplates.Components; +using Exceptionless.EmailTemplates.Components; using Exceptionless.EmailTemplates.Models; using Exceptionless.EmailTemplates.Templates; using Microsoft.AspNetCore.Components; @@ -34,6 +34,7 @@ public Task RenderAsync(EmailTemplate template, CancellationToken cancel OrganizationInvitedEmail value => RenderComponentAsync(value), OrganizationNoticeEmail value => RenderComponentAsync(value), OrganizationPaymentFailedEmail value => RenderComponentAsync(value), + OrganizationTrialEndingEmail value => RenderComponentAsync(value), ProjectDailySummaryEmail value => RenderComponentAsync(value), UserEmailVerifyEmail value => RenderComponentAsync(value), UserPasswordResetEmail value => RenderComponentAsync(value), diff --git a/src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor b/src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor new file mode 100644 index 0000000000..2de8ac35de --- /dev/null +++ b/src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor @@ -0,0 +1,12 @@ +@namespace Exceptionless.EmailTemplates.Templates +@inherits EmailTemplateComponent + + + +

Your trial ends soon

+

The free trial for "@Model.OrganizationName" ends on @Model.TrialEndsDate. Upgrade now to keep everything you've been using.

+

When the trial ends, @Model.OrganizationName moves to the Free plan: 3,000 events per month, 3 days of data retention, and no Exie, alerts, or integrations. Events older than 3 days will be deleted.

+ Upgrade Plan +

Send us an email at support@exceptionless.io if you have any questions or need assistance.

+
+
diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index 8cceab0727..cc32ce120f 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -1,4 +1,4 @@ -namespace Exceptionless.Job; +namespace Exceptionless.Job; public class JobRunnerOptions { @@ -83,6 +83,10 @@ public JobRunnerOptions(string[] args) if (StackEventCount && args.Length != 0) JobName = nameof(StackEventCount); + TrialExpiration = args.Length == 0 || args.Contains(nameof(TrialExpiration), StringComparer.OrdinalIgnoreCase); + if (TrialExpiration && args.Length != 0) + JobName = nameof(TrialExpiration); + WebHooks = args.Length == 0 || args.Contains(nameof(WebHooks), StringComparer.OrdinalIgnoreCase); if (WebHooks && args.Length != 0) JobName = nameof(WebHooks); @@ -112,6 +116,7 @@ public JobRunnerOptions(string[] args) public bool RunDataSeedStartupAction => !Migration; public bool StackStatus { get; } public bool StackEventCount { get; } + public bool TrialExpiration { get; } public bool WebHooks { get; } public bool WorkItem { get; } } diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 0e65530af8..40c5cfc6f6 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using Exceptionless.Core; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; @@ -188,6 +188,11 @@ private static void AddJobs(IServiceCollection services, JobRunnerOptions option services.AddJob(o => o.WaitForStartupActions()); if (options.StackEventCount) services.AddJob(o => o.WaitForStartupActions()); + if (options is { TrialExpiration: true, AllJobs: true }) + services.AddCronJob("15 * * * *"); + if (options is { TrialExpiration: true, AllJobs: false }) + services.AddJob(); + if (options.WebHooks) services.AddJob(o => o.WaitForStartupActions()); if (options.WorkItem) diff --git a/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs index 5082053735..06d7bafa01 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Authorization; @@ -247,6 +247,9 @@ private static async Task SetConversationSharingAsync(UpdateAssistantCo AssistantAccessReason.UpgradeRequired => HttpResults.Problem( statusCode: StatusCodes.Status426UpgradeRequired, title: access.Message), + AssistantAccessReason.EmailVerificationRequired => HttpResults.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: access.Message), _ => HttpResults.NotFound() }; diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index c758517674..171207f3a0 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Messaging.Models; @@ -274,10 +274,13 @@ public async Task> Handle(AdminChangePlan message) if (plan is null) return new ChangePlanResponse(false, "Invalid PlanId."); - organization.BillingStatus = !String.Equals(plan.Id, plans.FreePlan.Id) ? BillingStatus.Active : BillingStatus.Trialing; + organization.BillingStatus = !plans.IsFreeOrTrialPlan(plan.Id) ? BillingStatus.Active : BillingStatus.Trialing; organization.RemoveSuspension(); var currentUser = httpContext.Request.GetUser(); - billingManager.ApplyBillingPlan(organization, plan, currentUser, false); + if (plans.IsTrialPlan(plan.Id)) + billingManager.ApplyTrialPlan(organization, currentUser); + else + billingManager.ApplyBillingPlan(organization, plan, currentUser, false); await organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); await messagePublisher.PublishAsync(new PlanChanged diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 574c7cb899..d3de4f1fef 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -633,8 +633,9 @@ public async Task> Handle(AddOrganizationUser message) if (organization is null) return Result.NotFound("Organization not found."); - if (!await billingManager.CanAddUserAsync(organization)) - return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.PlanLimit, "Please upgrade your plan to add an additional user.")); + var inviteLimitResult = await CheckInviteLimitAsync(organization, message.Context); + if (inviteLimitResult is not null) + return inviteLimitResult; var user = await userRepository.GetByEmailAddressAsync(message.Email); if (user is not null) @@ -674,6 +675,29 @@ await messagePublisher.PublishAsync(new UserMembershipChanged return new User { EmailAddress = message.Email }; } + private async Task?> CheckInviteLimitAsync(Organization organization, HttpContext httpContext) + { + if (!options.StripeOptions.EnableBilling || httpContext.Request.IsGlobalAdmin()) + return null; + + var currentUser = GetCurrentUser(httpContext); + if (!currentUser.IsEmailAddressVerified) + return Result.Forbidden("Please verify your email address before inviting users."); + + int dailyLimit = plans.IsFreeOrTrialPlan(organization.PlanId) + ? options.BillingOptions.FreeInviteDailyLimit + : options.BillingOptions.PaidInviteDailyLimit; + + var utcNow = timeProvider.GetUtcNow().UtcDateTime; + string cacheKey = $"user:{currentUser.Id}:organization-invites:{utcNow:yyyyMMdd}"; + long invites = await cacheClient.IncrementAsync(cacheKey, 1, utcNow.Date.AddDays(1)); + if (invites <= dailyLimit) + return null; + + _logger.LogWarning("User {UserId} reached the daily invite limit of {DailyLimit} for organization {OrganizationId}", currentUser.Id, dailyLimit, organization.Id); + return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.RateLimit, "You've sent the maximum number of invites for today. Please try again tomorrow or contact support.")); + } + public async Task Handle(RemoveOrganizationUser message) { var organization = await GetModelAsync(message.Id, useCache: false); @@ -831,10 +855,12 @@ private async Task MapToViewAsync(Organization model) private async Task AddModelAsync(Organization value, HttpContext httpContext) { var user = GetCurrentUser(httpContext); - var plan = !options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin) - ? plans.UnlimitedPlan - : plans.FreePlan; - billingManager.ApplyBillingPlan(value, plan, user); + if (!options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin)) + billingManager.ApplyBillingPlan(value, plans.UnlimitedPlan, user); + else if (billingManager.CanStartTrial(user)) + billingManager.StartTrial(value, user); + else + billingManager.ApplyBillingPlan(value, plans.FreePlan, user); var organization = await repository.AddAsync(value, o => o.Cache()); diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 3705100472..8ea9ff24fc 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -1,6 +1,5 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Authorization; -using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.WorkItems; @@ -33,7 +32,6 @@ public class ProjectHandler( IEventRepository eventRepository, ITokenRepository tokenRepository, IQueue workItemQueue, - BillingManager billingManager, SlackService slackService, IOAuthProviderClient oauthProviderClient, SampleDataService sampleDataService, @@ -544,9 +542,6 @@ private async Task AfterResultMapAsync(ICollection m if (!await IsProjectNameAvailableInternalAsync(value.OrganizationId, value.Name, null, httpContext)) return Result.BadRequest("A project with this name already exists."); - if (!await billingManager.CanAddProjectAsync(value)) - return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.PlanLimit, "Please upgrade your plan to add additional projects.")); - return null; } diff --git a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs index 576312cf28..69cf6258ed 100644 --- a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; @@ -41,7 +41,11 @@ public async Task GetAccessAsync(HttpRequest request, s if (appOptions.AppMode == AppMode.Development) return AssistantAccessDecision.Available(billingPlans.UnlimitedPlan.Assistant!); - return EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); + var decision = EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); + if (decision.HasAccess && billingPlans.IsTrialPlan(organization.PlanId) && !request.GetUser().IsEmailAddressVerified) + return AssistantAccessDecision.EmailVerificationRequired("Verify your email address to use Exie during your trial."); + + return decision; } internal static AssistantAccessDecision? EvaluateConfiguration(AppOptions appOptions, bool? enabled = null) @@ -71,7 +75,8 @@ public enum AssistantAccessReason NotConfigured, OrganizationRequired, OrganizationNotAccessible, - UpgradeRequired + UpgradeRequired, + EmailVerificationRequired } public sealed record AssistantAccessDecision( @@ -85,6 +90,8 @@ public sealed record AssistantAccessDecision( { public static AssistantAccessDecision Available(AssistantPlanOptions? planOptions) => new(true, true, false, AssistantAccessReason.Available, null, planOptions, null); + public static AssistantAccessDecision EmailVerificationRequired(string message) => new(true, false, false, AssistantAccessReason.EmailVerificationRequired, message, null, null); + public static AssistantAccessDecision Unavailable( AssistantAccessReason reason, string message, @@ -92,5 +99,5 @@ public static AssistantAccessDecision Unavailable( bool upgradeRequired = false, string? minimumPlanId = null) => new(enabled, false, upgradeRequired, reason, message, null, minimumPlanId); - public AssistantAccessResponse ToResponse() => new(Enabled, HasAccess, UpgradeRequired, Message, MinimumPlanId); + public AssistantAccessResponse ToResponse() => new(Enabled, HasAccess, UpgradeRequired, Message, MinimumPlanId, Reason == AssistantAccessReason.EmailVerificationRequired); } diff --git a/src/Exceptionless.Web/Assistant/AssistantModels.cs b/src/Exceptionless.Web/Assistant/AssistantModels.cs index 222631ae90..55eac9d4a7 100644 --- a/src/Exceptionless.Web/Assistant/AssistantModels.cs +++ b/src/Exceptionless.Web/Assistant/AssistantModels.cs @@ -1,4 +1,4 @@ -namespace Exceptionless.Web.Assistant; +namespace Exceptionless.Web.Assistant; public sealed record AssistantChatRequest( IReadOnlyCollection Messages, @@ -34,7 +34,8 @@ public sealed record AssistantAccessResponse( bool HasAccess, bool UpgradeRequired, string? Message = null, - string? MinimumPlanId = null); + string? MinimumPlanId = null, + bool EmailVerificationRequired = false); public sealed record AssistantSuggestedAction(string Label, string Prompt, string? Href = null); diff --git a/src/Exceptionless.Web/Assistant/AssistantService.cs b/src/Exceptionless.Web/Assistant/AssistantService.cs index df1ad68c4a..dfe53d8e4e 100644 --- a/src/Exceptionless.Web/Assistant/AssistantService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantService.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text; @@ -130,7 +130,7 @@ internal async IAsyncEnumerable StreamAsync( if (diagnostics is not null) diagnostics.Stage = "usage_reservation"; - await using var providerRequest = await assistantUsageService.StartProviderRequestAsync(request.OrganizationId, providerInputCharacters); + await using var providerRequest = await assistantUsageService.StartProviderRequestAsync(request.OrganizationId, providerInputCharacters, planOptions); using var providerDiagnostics = diagnostics?.StartProviderRequest(cancellationToken); bool receivedDone = false; bool receivedFinishReason = false; diff --git a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs index 57ef27a40a..bbee5bd934 100644 --- a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; @@ -60,6 +60,13 @@ public async Task TryStartTurnAsync(string? organizati "This organization has reached Exie's monthly AI cost limit.")); } + var sharedDailyCostDecision = await GetSharedDailyCostDecisionAsync(planOptions, now); + if (sharedDailyCostDecision is not null) + { + await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByCostLimit = 1 }); + return AssistantTurnReservation.Blocked(sharedDailyCostDecision); + } + if (monthlyUsage.TotalTokens >= planOptions.MaximumMonthlyTokens) { await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByTokenLimit = 1 }); @@ -129,6 +136,13 @@ public async Task TryContinueTurnAsync(string? organizat "This organization reached Exie's monthly AI cost limit while completing the response."); } + var sharedDailyCostDecision = await GetSharedDailyCostDecisionAsync(planOptions, now); + if (sharedDailyCostDecision is not null) + { + await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByCostLimit = 1 }); + return sharedDailyCostDecision; + } + if (monthlyUsage.TotalTokens >= planOptions.MaximumMonthlyTokens) { await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByTokenLimit = 1 }); @@ -169,9 +183,12 @@ await Task.WhenAll( }; } - public async Task StartProviderRequestAsync(string? organizationId, int providerInputCharacters = 0) + public async Task StartProviderRequestAsync(string? organizationId, int providerInputCharacters = 0, AssistantPlanOptions? planOptions = null) { var reservation = await RecordProviderRequestStartedAsync(organizationId, providerInputCharacters); + if (planOptions is { SharedDailyCostLimitKey: not null, MaximumSharedDailyCostUsd: > 0 }) + reservation = reservation with { SharedDailyCostLimitKey = planOptions.SharedDailyCostLimitKey }; + return new AssistantProviderUsageLifecycle(this, organizationId, reservation); } @@ -293,8 +310,34 @@ await UpdateProviderUsageCacheWithInitializationAsync( reverse: true); throw; } + + if (reservation?.SharedDailyCostLimitKey is { } sharedDailyCostLimitKey && costInMicrodollars > 0) + { + var day = GetDayWindow(usageDate); + await _cache.IncrementAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id), costInMicrodollars, day.ResetAtUtc.UtcDateTime); + } } + private async Task GetSharedDailyCostDecisionAsync(AssistantPlanOptions planOptions, DateTimeOffset now) + { + if (planOptions is not { SharedDailyCostLimitKey: { } sharedDailyCostLimitKey, MaximumSharedDailyCostUsd: > 0 }) + return null; + + var day = GetDayWindow(now); + var cost = await _cache.GetAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id)); + if (!cost.HasValue || cost.Value < ToMicrodollars(planOptions.MaximumSharedDailyCostUsd)) + return null; + + logger.LogWarning("Assistant shared daily cost limit reached for {SharedDailyCostLimitKey}", sharedDailyCostLimitKey); + return AssistantUsageDecision.Blocked( + AssistantUsageLimit.SharedDailyCost, + day.ResetAtUtc, + "Exie is at capacity for trial organizations right now. Upgrade your plan for guaranteed access."); + } + + private static string GetSharedDailyCostKey(string sharedDailyCostLimitKey, string dayId) + => $"shared:{sharedDailyCostLimitKey}:usage:{dayId}:cost-microdollars"; + private async Task UpdateProviderUsageCacheWithInitializationAsync( string organizationId, DateTimeOffset usageDate, @@ -477,6 +520,12 @@ private static UsageWindow GetMinuteWindow(DateTimeOffset now) return new UsageWindow(id.ToString(), DateTimeOffset.FromUnixTimeSeconds((id + 1) * 60)); } + private static UsageWindow GetDayWindow(DateTimeOffset now) + { + var start = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero); + return new UsageWindow(start.ToString("yyyyMMdd"), start.AddDays(1)); + } + private static UsageWindow GetMonthWindow(DateTimeOffset now) { var start = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); @@ -511,7 +560,8 @@ public enum AssistantUsageLimit ConcurrentTurns, MinuteTurns, MonthlyTokens, - MonthlyCost + MonthlyCost, + SharedDailyCost } public sealed record AssistantUsageDecision( @@ -535,6 +585,7 @@ public sealed record AssistantProviderReservation(long PromptTokens, long Comple internal string? MonthId { get; init; } internal DateTimeOffset? ExpiresAtUtc { get; init; } internal DateTime? UsageDateUtc { get; init; } + internal string? SharedDailyCostLimitKey { get; init; } } public sealed class AssistantProviderUsageLifecycle : IAsyncDisposable diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.test.ts index d27440f2ca..350282f865 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.test.ts @@ -15,6 +15,14 @@ describe('resolveAssistantAccessState', () => { ['organization', available, false, false, true, 'available'], ['organization', available, false, true, false, 'available'], ['organization', { enabled: true, has_access: false, upgrade_required: true }, false, false, false, 'upgrade-required'], + [ + 'organization', + { email_verification_required: true, enabled: true, has_access: false, upgrade_required: false }, + false, + false, + false, + 'verification-required' + ], ['organization', { enabled: false, has_access: false, upgrade_required: false }, false, false, false, 'disabled'] ] as const)('resolves an access-query state', (organizationId, access, isPending, isError, isFetching, expected) => { expect(resolveAssistantAccessState(organizationId, access, isPending, isError, isFetching)).toBe(expected); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.ts index 7dc2942d4e..8c2e3c7289 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/access-state.ts @@ -20,6 +20,10 @@ export function resolveAssistantAccessState( return 'available'; } + if (access.email_verification_required) { + return 'verification-required'; + } + return access.upgrade_required ? 'upgrade-required' : 'disabled'; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-upgrade-required.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-upgrade-required.svelte index 6d110b54e2..9af0e75a1c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-upgrade-required.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-upgrade-required.svelte @@ -1,4 +1,6 @@ - {name} is currently on a free plan. + + {#if trialEnded} + Your trial has ended. {name} is now on the Free plan. + {:else} + {name} is currently on a free plan. + {/if} + + The Free plan includes {maxEventsPerMonth.toLocaleString()} events per month and {retentionDays} days of data retention, without Exie, alerts, or integrations. showUpgradeDialog(organizationId, 'Upgrade to enable premium features and extra storage.')}>Upgrade now - to enable premium features and extra storage! + to get them back. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts index a6fda428b4..1e6dee788c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts @@ -6,6 +6,7 @@ import ProjectConfigurationNotification from './project-configuration-notificati import RequestLimitNotification from './request-limit-notification.svelte'; import SetupFirstProjectNotification from './setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './suspended-organization-notification.svelte'; +import TrialNotification from './trial-notification.svelte'; export type { NotificationProps } from '#comp/notification/index.ts'; @@ -17,5 +18,6 @@ export { ProjectConfigurationNotification, RequestLimitNotification, SetupFirstProjectNotification, - SuspendedOrganizationNotification + SuspendedOrganizationNotification, + TrialNotification }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte index a09d43ad55..37db3e0cce 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte @@ -15,6 +15,7 @@ import RequestLimitNotification from './request-limit-notification.svelte'; import SetupFirstProjectNotification from './setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './suspended-organization-notification.svelte'; + import TrialNotification from './trial-notification.svelte'; const mockProjects: ViewProject[] = [ { @@ -49,8 +50,17 @@ }); + + + + + + - + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte new file mode 100644 index 0000000000..f6f93401ee --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte @@ -0,0 +1,26 @@ + + + + {daysLabel} left in the {name} free trial. + + showUpgradeDialog(organizationId, 'Upgrade to keep Exie, alerts, integrations, and 90-day retention.')}>Upgrade now + to keep Exie, alerts, integrations, and 90-day retention when your trial ends. + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index cb43a6ea42..c487ac3da3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -26,6 +26,8 @@ import type { NotificationProps } from '#comp/notification/index.ts'; + import { FREE_PLAN_ID, TRIAL_PLAN_ID } from '#features/billing/constants.ts'; + import { getTrialDaysRemaining } from '#features/billing/trial.ts'; import { getOrganizationQuery, getOrganizationsQuery } from '#features/organizations/api.svelte.ts'; import { organization as currentOrganizationId } from '#features/organizations/context.svelte.ts'; import { SuspensionCode } from '#features/organizations/models.ts'; @@ -41,25 +43,17 @@ import RequestLimitNotification from './notifications/request-limit-notification.svelte'; import SetupFirstProjectNotification from './notifications/setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './notifications/suspended-organization-notification.svelte'; + import TrialNotification from './notifications/trial-notification.svelte'; interface Props extends NotificationProps { ignoreConfigureProjects?: boolean; - ignoreFree?: boolean; isChatEnabled: boolean; openChat: () => void; premiumFeatureName?: string; requiresPremium?: boolean; } - let { - ignoreConfigureProjects = false, - ignoreFree = false, - isChatEnabled, - openChat, - premiumFeatureName, - requiresPremium = false, - ...restProps - }: Props = $props(); + let { ignoreConfigureProjects = false, isChatEnabled, openChat, premiumFeatureName, requiresPremium = false, ...restProps }: Props = $props(); const meQuery = getMeQuery(); const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); @@ -109,7 +103,9 @@ const isMonthlyOverage = $derived(!!organization?.is_over_monthly_limit); const isHourlyOverage = $derived(!!organization?.is_throttled); const isExceededRequestLimit = $derived(!!organization?.is_over_request_limit); - const isFreePlan = $derived(!ignoreFree && /free/i.test(organization?.plan_name ?? '')); + const isFreePlan = $derived(organization?.plan_id === FREE_PLAN_ID); + const isTrialPlan = $derived(organization?.plan_id === TRIAL_PLAN_ID); + const trialDaysRemaining = $derived(getTrialDaysRemaining(organization?.trial_ends_utc)); const hasNoProjects = $derived(projectsQuery.isSuccess && projects.length === 0); const needsProjectConfiguration = $derived( projectsQuery.isSuccess && !ignoreConfigureProjects && projects.length > 0 && projectsNeedingConfig.length === projects.length @@ -146,8 +142,17 @@ {:else if requiresPremiumUpgrade} + {:else if isTrialPlan && trialDaysRemaining > 0} + {:else if isFreePlan} - + {:else if hasNoProjects} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 1664c77105..1345da5385 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -79,6 +79,7 @@ export interface AssistantAccessResponse { upgrade_required: boolean; message?: null | string; minimum_plan_id?: null | string; + email_verification_required: boolean; } export interface AssistantChatMessage { @@ -779,6 +780,11 @@ export interface User { assistant_conversation_sharing_enabled?: null | boolean; is_email_address_verified: boolean; verify_email_address_token?: null | string; + /** + * Date the user started their one no-card trial. + * @format date-time + */ + trial_started_utc?: null | string; /** @format date-time */ verify_email_address_token_expiration: string; /** Gets or sets the users active state. */ @@ -881,6 +887,8 @@ export interface ViewOrganization { billing_status: BillingStatus; /** @format double */ billing_price: number; + /** @format date-time */ + trial_ends_utc?: null | string; /** @format int32 */ max_events_per_month: number; /** @format int32 */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 71367e1d2f..d0db1d1010 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -84,6 +84,7 @@ export const AssistantAccessResponseSchema = object({ .min(1, "Minimum plan id is required") .nullable() .optional(), + email_verification_required: boolean(), }); export type AssistantAccessResponseFormData = Infer< typeof AssistantAccessResponseSchema @@ -915,6 +916,7 @@ export const UserSchema = object({ .min(1, "Verify email address token is required") .nullable() .optional(), + trial_started_utc: iso.datetime().nullable().optional(), verify_email_address_token_expiration: iso.datetime(), is_active: boolean(), roles: array(string()), @@ -1025,6 +1027,7 @@ export const ViewOrganizationSchema = object({ .optional(), billing_status: BillingStatusSchema, billing_price: number(), + trial_ends_utc: iso.datetime().nullable().optional(), max_events_per_month: int32(), bonus_events_per_month: int32(), bonus_expiration: iso.datetime().nullable().optional(), diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 5c46965b96..be27607ff5 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -25,6 +25,7 @@ public record ViewOrganization : IIdentity, IData, IHaveDates public string? BillingChangedByUserId { get; set; } public BillingStatus BillingStatus { get; set; } public decimal BillingPrice { get; set; } + public DateTime? TrialEndsUtc { get; set; } public int MaxEventsPerMonth { get; set; } public int BonusEventsPerMonth { get; set; } public DateTime? BonusExpiration { get; set; } diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index f8abf4f1e5..4501cdc80e 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11881,7 +11881,8 @@ "required": [ "enabled", "has_access", - "upgrade_required" + "upgrade_required", + "email_verification_required" ], "type": "object", "properties": { @@ -11905,6 +11906,10 @@ "null", "string" ] + }, + "email_verification_required": { + "type": "boolean", + "default": false } } }, @@ -14232,6 +14237,14 @@ "string" ] }, + "trial_started_utc": { + "type": [ + "null", + "string" + ], + "description": "Date the user started their one no-card trial.", + "format": "date-time" + }, "verify_email_address_token_expiration": { "type": "string", "format": "date-time" @@ -14626,6 +14639,13 @@ "type": "number", "format": "double" }, + "trial_ends_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, "max_events_per_month": { "type": "integer", "format": "int32" diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs index 21989facec..45d7092b94 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using System.Text.Json; using Exceptionless.Core.Extensions; @@ -186,6 +186,11 @@ public Task SendOrganizationPaymentFailedAsync(User owner, Organization organiza return Task.CompletedTask; } + public Task SendOrganizationTrialEndingAsync(User user, Organization organization) + { + return Task.CompletedTask; + } + public Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan) { return Task.CompletedTask; diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 7fcf38ad6d..a3261d5942 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -1,5 +1,6 @@ -using System.Net; +using System.Net; using Exceptionless.Core; +using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -11,6 +12,7 @@ using Exceptionless.Tests.Utility; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; +using FluentRest; using Foundatio.Repositories; using Foundatio.Repositories.Utility; using Microsoft.Extensions.DependencyInjection; @@ -1175,18 +1177,6 @@ public Task AddUserAsync_NonExistentOrganization_ReturnsNotFound() ); } - [Fact] - public Task AddUserAsync_PlanLimitReached_ReturnsUpgradeRequired() - { - // Act & Assert - return SendRequestAsync(r => r - .AsFreeOrganizationUser() - .Post() - .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "users", "new.member@localhost") - .StatusCodeShouldBeUpgradeRequired() - ); - } - [Fact] public async Task RemoveUserAsync_UserWithNotificationSettings_CleansUpNotificationSettings() { @@ -2415,52 +2405,6 @@ public async Task ChangePlanAsync_ExistingCustomerSubscriptionFails_DoesNotPersi Assert.Equal(BillingStatus.Trialing, organization.BillingStatus); } - [Fact] - public async Task CanDownGradeAsync_TooManyUsers_ReturnsFailure() - { - // Arrange — test organization has 2 users (global admin + organization user); free plan allows max 1 - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); - Assert.NotNull(organization); - - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); - Assert.NotNull(user); - - // Act - var result = await _billingManager.CanDownGradeAsync(organization, _plans.FreePlan, user); - - // Assert - Assert.False(result.Success); - Assert.Contains("remove", result.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("user", result.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CanDownGradeAsync_TooManyProjects_ReturnsFailure() - { - // Arrange — free organization has 1 user and 1 project; add a second project so project check fails - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); - Assert.NotNull(organization); - - var extraProject = new Project - { - Name = "Extra Project", - OrganizationId = organization.Id, - NextSummaryEndOfDayTicks = TimeProvider.GetUtcNow().UtcDateTime.Date.AddDays(1).AddHours(1).Ticks - }; - await _projectRepository.AddAsync(extraProject, o => o.ImmediateConsistency()); - - var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); - Assert.NotNull(user); - - // Act - var result = await _billingManager.CanDownGradeAsync(organization, _plans.FreePlan, user); - - // Assert - Assert.False(result.Success); - Assert.Contains("remove", result.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("project", result.Message, StringComparison.OrdinalIgnoreCase); - } - [Fact] public async Task CanDownGradeAsync_AlreadyHasFreePlan_ReturnsFailure() { @@ -2790,6 +2734,221 @@ public async Task PatchAsync_EmptyJsonBody_ReturnsOriginalOrganizationUnchanged( Assert.Equal(originalOrganization.UpdatedUtc, updated.UpdatedUtc); } + [Fact] + public async Task PostAsync_FirstOrganizationWithBillingEnabled_StartsTrialAndAllowsOnlyOne() + { + // Arrange + const string password = "Trial password"; + var user = await CreateUserAsync("trial-owner@exceptionless.test", password, isEmailAddressVerified: true); + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + + // Act + (ViewOrganization? organizationView, Microsoft.AspNetCore.Mvc.ProblemDetails? secondOrganizationProblem) = await WithBillingEnabledAsync(async () => + { + var created = await SendRequestAsAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Trial Organization" }) + .StatusCodeShouldBeCreated() + ); + + var problem = await SendRequestAsAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Second Organization" }) + .StatusCodeShouldBeUpgradeRequired() + ); + + return ((ViewOrganization?)created, (Microsoft.AspNetCore.Mvc.ProblemDetails?)problem); + }); + + // Assert + Assert.NotNull(organizationView); + Assert.Equal(_plans.TrialPlan.Id, organizationView.PlanId); + Assert.True(organizationView.HasPremiumFeatures); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, organizationView.MaxEventsPerMonth); + Assert.NotNull(organizationView.TrialEndsUtc); + Assert.InRange(organizationView.TrialEndsUtc.Value, utcNow.AddDays(7).AddMinutes(-1), utcNow.AddDays(7).AddMinutes(1)); + Assert.NotNull(secondOrganizationProblem); + + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.NotNull(updatedUser.TrialStartedUtc); + } + + [Fact] + public async Task PostAsync_UserWhoAlreadyStartedTrial_GetsFreePlan() + { + // Arrange + const string password = "Trial password"; + var user = await CreateUserAsync("already-trialed@exceptionless.test", password, isEmailAddressVerified: true, trialStartedUtc: TimeProvider.GetUtcNow().UtcDateTime.AddMonths(-1)); + + // Act + var organizationView = await WithBillingEnabledAsync(() => SendRequestAsAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "No Second Trial" }) + .StatusCodeShouldBeCreated() + )); + + // Assert + Assert.NotNull(organizationView); + Assert.Equal(_plans.FreePlan.Id, organizationView.PlanId); + Assert.Null(organizationView.TrialEndsUtc); + } + + [Fact] + public async Task PostAsync_UserWithExistingOrganization_GetsFreePlan() + { + // Act + var organizationView = await WithBillingEnabledAsync(() => SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Existing Member Organization" }) + .StatusCodeShouldBeCreated() + )); + + // Assert + Assert.NotNull(organizationView); + Assert.Equal(_plans.FreePlan.Id, organizationView.PlanId); + } + + [Fact] + public async Task ChangePlanAsync_TrialToPaidPlan_StartsBillingAndEndsTrial() + { + // Arrange + const string password = "Trial password"; + var user = await CreateUserAsync("trial-upgrade@exceptionless.test", password, isEmailAddressVerified: true); + var organization = await CreateTrialOrganizationAsync(user); + StripeBillingClient.CustomerToReturn = new Customer { Id = "cus_trial" }; + + // Act + var result = await WithBillingEnabledAsync(() => SendRequestAsAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPaths("organizations", organization.Id, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.MediumPlan.Id, StripeToken = "tok_visa", Last4 = "4242" }) + .StatusCodeShouldBeOk() + )); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success, result.Message); + var subscription = Assert.Single(StripeBillingClient.CreatedSubscriptionOptions); + Assert.Equal(_plans.MediumPlan.Id, Assert.Single(subscription.Items).Price); + Assert.Null(subscription.TrialEnd); + + var updated = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(updated); + Assert.Equal(_plans.MediumPlan.Id, updated.PlanId); + Assert.Equal(BillingStatus.Active, updated.BillingStatus); + } + + [Fact] + public async Task ChangePlanAsync_TrialToFreePlan_EndsTrialWithoutStripe() + { + // Arrange + const string password = "Trial password"; + var user = await CreateUserAsync("trial-downgrade@exceptionless.test", password, isEmailAddressVerified: true); + var organization = await CreateTrialOrganizationAsync(user); + + // Act + var result = await WithBillingEnabledAsync(() => SendRequestAsAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPaths("organizations", organization.Id, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeOk() + )); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success, result.Message); + Assert.Empty(StripeBillingClient.CreatedSubscriptionOptions); + + var updated = await _organizationRepository.GetByIdAsync(organization.Id); + Assert.NotNull(updated); + Assert.Equal(_plans.FreePlan.Id, updated.PlanId); + Assert.False(updated.HasPremiumFeatures); + } + + [Fact] + public Task AddUserAsync_UnverifiedInviterWithBillingEnabled_ReturnsForbidden() + { + // Act & Assert + return WithBillingEnabledAsync(() => SendRequestAsync(r => r + .AsFreeOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "users", "new.member@localhost") + .StatusCodeShouldBeForbidden() + )); + } + + [Fact] + public async Task AddUserAsync_DailyInviteLimitReached_ReturnsTooManyRequests() + { + // Arrange + const string password = "Trial password"; + var user = await CreateUserAsync("trial-inviter@exceptionless.test", password, isEmailAddressVerified: true); + var organization = await CreateTrialOrganizationAsync(user); + int dailyLimit = GetService().BillingOptions.FreeInviteDailyLimit; + + // Act & Assert + await WithBillingEnabledAsync(async () => + { + for (int index = 0; index < dailyLimit; index++) + { + await SendRequestAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPaths("organizations", organization.Id, "users", $"invitee-{index}@exceptionless.test") + .StatusCodeShouldBeOk() + ); + } + + await SendRequestAsync(r => r + .BasicAuthorization(user.EmailAddress, password) + .Post() + .AppendPaths("organizations", organization.Id, "users", "one-too-many@exceptionless.test") + .ExpectedStatus(HttpStatusCode.TooManyRequests) + ); + }); + } + + private Task CreateUserAsync(string email, string password, bool isEmailAddressVerified, DateTime? trialStartedUtc = null) + { + string salt = StringExtensions.GetRandomString(16); + var user = new User + { + EmailAddress = email, + FullName = "Trial User", + Password = password.ToSaltedHash(salt), + Salt = salt, + TrialStartedUtc = trialStartedUtc + }; + user.Roles.Add(AuthorizationRoles.Client); + user.Roles.Add(AuthorizationRoles.User); + if (isEmailAddressVerified) + user.MarkEmailAddressVerified(); + + return _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + } + + private async Task CreateTrialOrganizationAsync(User user) + { + var organization = new Organization { Name = "Trial Organization" }; + _billingManager.StartTrial(organization, user); + organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency()); + + user.OrganizationIds.Add(organization.Id); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency()); + return organization; + } + private static MultipartFormDataContent CreateProfileImageContent() { byte[] bytes = new byte[256 * 1024]; diff --git a/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs index 0b15a79b36..73370b8765 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Configuration; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; @@ -122,6 +122,11 @@ public Task SendOrganizationPaymentFailedAsync(User owner, Organization organiza return Task.CompletedTask; } + public Task SendOrganizationTrialEndingAsync(User user, Organization organization) + { + return Task.CompletedTask; + } + public Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan) { return Task.CompletedTask; diff --git a/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs b/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs index 0721e503e8..bbba25e8a6 100644 --- a/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs +++ b/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -7,6 +7,7 @@ using Exceptionless.Core.Services; using Exceptionless.Tests.Utility; using Exceptionless.Web.Assistant; +using Exceptionless.Web.Extensions; using Foundatio.Repositories; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -133,6 +134,7 @@ public void EvaluatePlan_ProductionMediumOrHigherAllowsAccess() [Theory] [InlineData("EX_FREE", false)] + [InlineData("EX_TRIAL", true)] [InlineData("EX_SMALL", false)] [InlineData("EX_SMALL_YEARLY", false)] [InlineData("EX_MEDIUM", true)] @@ -159,6 +161,9 @@ public void AssistantPlanOptions_HaveConfiguredTiers() { var billingPlans = new BillingPlans(CreateOptions()); + AssertPlan(billingPlans.TrialPlan.Assistant, 1, 5, 5_000_000, 1m); + Assert.Equal("trial", billingPlans.TrialPlan.Assistant!.SharedDailyCostLimitKey); + Assert.Equal(25m, billingPlans.TrialPlan.Assistant.MaximumSharedDailyCostUsd); AssertPlan(billingPlans.MediumPlan.Assistant, 2, 10, 25_000_000, 5m); Assert.Same(billingPlans.MediumPlan.Assistant, billingPlans.MediumYearlyPlan.Assistant); AssertPlan(billingPlans.LargePlan.Assistant, 3, 15, 50_000_000, 10m); @@ -236,6 +241,32 @@ public async Task GetAccessAsync_Development_BypassesPlanOnlyAfterOrganizationVa repository.VerifyNoOtherCalls(); } + [Theory] + [InlineData(false, false, AssistantAccessReason.EmailVerificationRequired)] + [InlineData(true, true, AssistantAccessReason.Available)] + public async Task GetAccessAsync_TrialPlan_RequiresVerifiedEmail(bool isEmailAddressVerified, bool expectedAccess, AssistantAccessReason expectedReason) + { + // Arrange + var options = CreateOptions(new Dictionary { ["Assistant:ApiKey"] = "test-key" }); + var billingPlans = new BillingPlans(options); + var organization = new Organization { Id = TestConstants.OrganizationId, Name = "Trial", PlanId = billingPlans.TrialPlan.Id }; + var repository = new Mock(); + repository.Setup(repository => repository.GetByIdAsync(organization.Id, It.IsAny>())) + .ReturnsAsync(organization); + var service = new AssistantAccessService(options, billingPlans, repository.Object, CreateSystemSettingsService(options)); + var request = CreateRequest(organization.Id); + request.GetUser().IsEmailAddressVerified = isEmailAddressVerified; + + // Act + var access = await service.GetAccessAsync(request, organization.Id); + + // Assert + Assert.Equal(expectedAccess, access.HasAccess); + Assert.Equal(expectedReason, access.Reason); + Assert.False(access.UpgradeRequired); + Assert.Equal(!isEmailAddressVerified, access.ToResponse().EmailVerificationRequired); + } + private static void AssertPlan(AssistantPlanOptions? options, int concurrentTurns, int turnsPerMinute, long monthlyTokens, decimal monthlyCost) { Assert.NotNull(options); @@ -288,6 +319,7 @@ private static HttpRequest CreateRequest(params string[] organizationIds) { User = new System.Security.Claims.ClaimsPrincipal(user.ToIdentity()) }; + context.Request.SetUser(user); return context.Request; } } diff --git a/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs b/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs index e4cbb6bd13..a3e35b2279 100644 --- a/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs +++ b/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; @@ -39,6 +39,37 @@ public async Task TryStartTurnAsync_SharedCache_EnforcesLimitAcrossServiceInstan Assert.Equal(new DateTimeOffset(2026, 8, 2, 12, 1, 0, TimeSpan.Zero), blocked.ResetAtUtc); } + [Fact] + public async Task TryStartTurnAsync_SharedDailyCostLimitReached_BlocksEveryOrganizationSharingTheLimitUntilTomorrow() + { + // Arrange + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 8, 2, 12, 0, 0, TimeSpan.Zero)); + using var cache = CreateCache(timeProvider); + var service = CreateService(cache, CreateOptions(), timeProvider); + var sharedPlanOptions = CreatePlanOptions() with { SharedDailyCostLimitKey = "trial", MaximumSharedDailyCostUsd = 1m }; + var unsharedPlanOptions = CreatePlanOptions(); + + foreach ((string organizationId, decimal costUsd) in new[] { ("first-organization", 0.6m), ("second-organization", 0.5m) }) + { + await using var providerRequest = await service.StartProviderRequestAsync(organizationId, 100, sharedPlanOptions); + providerRequest.MarkAccepted(); + await providerRequest.ReconcileAsync(new AssistantProviderUsage(1_000, 100, costUsd)); + } + + // Act + await using var blocked = await service.TryStartTurnAsync("third-organization", sharedPlanOptions); + await using var unshared = await service.TryStartTurnAsync("paid-organization", unsharedPlanOptions); + timeProvider.Advance(TimeSpan.FromDays(1)); + await using var nextDay = await service.TryStartTurnAsync("third-organization", sharedPlanOptions); + + // Assert + Assert.False(blocked.Allowed); + Assert.Equal(AssistantUsageLimit.SharedDailyCost, blocked.Limit); + Assert.Equal(new DateTimeOffset(2026, 8, 3, 0, 0, 0, TimeSpan.Zero), blocked.ResetAtUtc); + Assert.True(unshared.Allowed); + Assert.True(nextDay.Allowed); + } + [Fact] public async Task TryStartTurnAsync_ConcurrentRequests_AtomicallyReservesConfiguredLimit() { diff --git a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs new file mode 100644 index 0000000000..98be5e4dee --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs @@ -0,0 +1,96 @@ +using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Jobs; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Tests.Mail; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +using Foundatio.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Exceptionless.Tests.Jobs; + +public class TrialExpirationJobTests : IntegrationTestsBase +{ + private const string TrialOrganizationId = "664ec4c1f12e4f2b7a0d3001"; + private const string PaidOrganizationId = "664ec4c1f12e4f2b7a0d3002"; + private const string VerifiedUserId = "664ec4c1f12e4f2b7a0d4001"; + private const string UnverifiedUserId = "664ec4c1f12e4f2b7a0d4002"; + + public TrialExpirationJobTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { } + + private CountingMailer Mailer => GetService(); + private TrialExpirationJob Job => GetService(); + private IOrganizationRepository OrganizationRepository => GetService(); + private IUserRepository UserRepository => GetService(); + private BillingManager BillingManager => GetService(); + private BillingPlans BillingPlans => GetService(); + private OrganizationData OrganizationData => GetService(); + private UserData UserData => GetService(); + + protected override void RegisterServices(IServiceCollection services) + { + base.RegisterServices(services); + services.AddSingleton(); + services.ReplaceSingleton(sp => sp.GetRequiredService()); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + Mailer.Reset(); + + var trialOrganization = new Organization { Id = TrialOrganizationId, Name = "Trial Organization" }; + BillingManager.ApplyTrialPlan(trialOrganization); + var paidOrganization = OrganizationData.GenerateOrganization(BillingManager, BillingPlans, id: PaidOrganizationId, name: "Paid Organization", plan: BillingPlans.MediumPlan); + await OrganizationRepository.AddAsync([trialOrganization, paidOrganization], options => options.ImmediateConsistency()); + + var verifiedUser = UserData.GenerateUser(id: VerifiedUserId, organizationId: TrialOrganizationId, emailAddress: "trial-owner@example.org"); + verifiedUser.MarkEmailAddressVerified(); + var unverifiedUser = UserData.GenerateUser(id: UnverifiedUserId, organizationId: TrialOrganizationId, emailAddress: "trial-member@example.org"); + unverifiedUser.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + await UserRepository.AddAsync([verifiedUser, unverifiedUser], options => options.ImmediateConsistency()); + } + + [Fact] + public async Task RunAsync_TrialLifecycle_SendsOneReminderThenMovesToFreePlan() + { + // Arrange + TimeProvider.Advance(TimeSpan.FromDays(5.5)); + + // Act + await Job.RunAsync(TestCancellationToken); + await Job.RunAsync(TestCancellationToken); + + // Assert + var remindedOrganization = await OrganizationRepository.GetByIdAsync(TrialOrganizationId); + Assert.NotNull(remindedOrganization); + Assert.Equal(BillingPlans.TrialPlan.Id, remindedOrganization.PlanId); + Assert.NotNull(remindedOrganization.TrialReminderSentUtc); + + var reminder = Assert.Single(Mailer.OrganizationTrialEndingCalls); + Assert.Equal(new OrganizationTrialEndingCall(VerifiedUserId, TrialOrganizationId), reminder); + + // Arrange + TimeProvider.Advance(TimeSpan.FromDays(2)); + + // Act + await Job.RunAsync(TestCancellationToken); + + // Assert + var expiredOrganization = await OrganizationRepository.GetByIdAsync(TrialOrganizationId); + Assert.NotNull(expiredOrganization); + Assert.Equal(BillingPlans.FreePlan.Id, expiredOrganization.PlanId); + Assert.False(expiredOrganization.HasPremiumFeatures); + Assert.Equal(BillingPlans.FreePlan.RetentionDays, expiredOrganization.RetentionDays); + Assert.NotNull(expiredOrganization.TrialEndsUtc); + Assert.Single(Mailer.OrganizationTrialEndingCalls); + + var paidOrganization = await OrganizationRepository.GetByIdAsync(PaidOrganizationId); + Assert.NotNull(paidOrganization); + Assert.Equal(BillingPlans.MediumPlan.Id, paidOrganization.PlanId); + } +} diff --git a/tests/Exceptionless.Tests/Mail/CountingMailer.cs b/tests/Exceptionless.Tests/Mail/CountingMailer.cs index 2967a84886..878f8df3f9 100644 --- a/tests/Exceptionless.Tests/Mail/CountingMailer.cs +++ b/tests/Exceptionless.Tests/Mail/CountingMailer.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Mail; +using Exceptionless.Core.Mail; using Exceptionless.Core.Models; namespace Exceptionless.Tests.Mail; @@ -11,6 +11,8 @@ public class CountingMailer : IMailer public List OrganizationNoticeCalls { get; } = []; + public List OrganizationTrialEndingCalls { get; } = []; + /// /// When true, throws instead of recording a call. /// Reset by . @@ -55,6 +57,15 @@ public Task SendOrganizationPaymentFailedAsync(User owner, Organization organiza return Task.CompletedTask; } + public Task SendOrganizationTrialEndingAsync(User user, Organization organization) + { + lock (OrganizationTrialEndingCalls) + { + OrganizationTrialEndingCalls.Add(new OrganizationTrialEndingCall(user.Id, organization.Id)); + } + return Task.CompletedTask; + } + public Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan) { return Task.CompletedTask; @@ -77,8 +88,14 @@ public void Reset() { OrganizationNoticeCalls.Clear(); } + lock (OrganizationTrialEndingCalls) + { + OrganizationTrialEndingCalls.Clear(); + } ShouldThrow = false; } } public record OrganizationNoticeCall(string UserId, string OrganizationId, bool IsOverMonthlyLimit, bool IsOverHourlyLimit); + +public record OrganizationTrialEndingCall(string UserId, string OrganizationId); diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 9aa950e99c..841a0232fd 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Text.Json; using System.Text.RegularExpressions; using Exceptionless.Core; @@ -565,6 +565,24 @@ public async Task SendOrganizationPaymentFailedAsync_WithOrganization_RendersBil Assert.Contains("Payment failed", body, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task SendOrganizationTrialEndingAsync_WithTrialOrganization_RendersUpgradeLink() + { + // Arrange + var user = _userData.GenerateSampleUser(); + var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + _billingManager.ApplyTrialPlan(organization); + + // Act + await _mailer.SendOrganizationTrialEndingAsync(user, organization); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("Your trial ends soon", body, StringComparison.Ordinal); + Assert.Contains($"organization/{organization.Id}/upgrade", body, StringComparison.Ordinal); + Assert.Contains("Events older than 3 days will be deleted", body, StringComparison.Ordinal); + } + [Fact] public async Task SendProjectDailySummaryAsync_WithSubmittedEvents_RendersTimelineLinks() { diff --git a/tests/Exceptionless.Tests/Mail/NullMailer.cs b/tests/Exceptionless.Tests/Mail/NullMailer.cs index 7ce54fcde6..4f5c2f2776 100644 --- a/tests/Exceptionless.Tests/Mail/NullMailer.cs +++ b/tests/Exceptionless.Tests/Mail/NullMailer.cs @@ -35,6 +35,11 @@ public Task SendOrganizationPaymentFailedAsync(User owner, Organization organiza return Task.CompletedTask; } + public Task SendOrganizationTrialEndingAsync(User user, Organization organization) + { + return Task.CompletedTask; + } + public Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan) { return Task.CompletedTask; From 34a8b474de49710afe928ba62496c91bb0472fe3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 08:40:39 -0500 Subject: [PATCH 02/35] Address review feedback on the free trial - Count only organizations a user owns for the one-free-organization rule and trial eligibility, so invited members can create their own organization (and get a trial) - Deny Exie for trial organizations whose trial has ended, without waiting for the expiration job - Count in-flight Exie reservations against the shared trial daily cap - Track free and paid invite allowances separately - Migrate existing organizations to unlimited users and projects - Show an ended-trial banner before the job moves the organization - Use an invariant date in the trial reminder email - Remove byte order marks added by earlier edits Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 31 +++++++--- src/Exceptionless.Core/Bootstrapper.cs | 2 +- .../Configuration/AppOptions.cs | 2 +- .../Jobs/TrialExpirationJob.cs | 2 +- src/Exceptionless.Core/Mail/IMailer.cs | 2 +- src/Exceptionless.Core/Mail/Mailer.cs | 5 +- ..._RemoveOrganizationUserAndProjectLimits.cs | 60 +++++++++++++++++++ src/Exceptionless.Core/Models/Organization.cs | 2 +- .../Repositories/OrganizationRepository.cs | 2 +- .../Models/EmailTemplate.cs | 2 +- .../RazorEmailTemplateRenderer.cs | 2 +- src/Exceptionless.Job/JobRunnerOptions.cs | 2 +- src/Exceptionless.Job/Program.cs | 2 +- .../Api/Endpoints/AssistantEndpoints.cs | 2 +- .../Api/Handlers/AdminHandler.cs | 2 +- .../Api/Handlers/OrganizationHandler.cs | 9 +-- .../Api/Handlers/ProjectHandler.cs | 2 +- .../Assistant/AssistantAccessService.cs | 18 ++++-- .../Assistant/AssistantModels.cs | 2 +- .../Assistant/AssistantService.cs | 2 +- .../Assistant/AssistantUsageService.cs | 31 ++++++++-- .../components/change-plan-dialog.stories.ts | 32 ++++++++++ .../components/change-plan-dialog.svelte | 2 +- .../organization-notifications.stories.svelte | 3 + .../notifications/trial-notification.svelte | 18 ++++-- .../organization-notifications.svelte | 2 +- .../Api/Endpoints/ContactEndpointTests.cs | 2 +- .../Endpoints/OrganizationEndpointTests.cs | 47 +++++++++++++-- .../Api/Handlers/ContactHandlerTests.cs | 2 +- .../Assistant/AssistantAccessServiceTests.cs | 22 ++++--- .../Assistant/AssistantUsageServiceTests.cs | 23 ++++++- .../Jobs/TrialExpirationJobTests.cs | 19 ++++++ .../Mail/CountingMailer.cs | 2 +- tests/Exceptionless.Tests/Mail/MailerTests.cs | 2 +- ...ationUserAndProjectLimitsMigrationTests.cs | 50 ++++++++++++++++ 35 files changed, 347 insertions(+), 63 deletions(-) create mode 100644 src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs create mode 100644 tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index cfa5fbffbb..559c7b9d89 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -36,17 +36,21 @@ public async Task CanAddOrganizationAsync(User? user) if (user is null) return false; - var organizations = await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray()); - return !organizations.Any(o => _plans.IsFreeOrTrialPlan(o.PlanId)); + var ownedOrganizations = await GetOwnedOrganizationsAsync(user); + return !ownedOrganizations.Any(o => _plans.IsFreeOrTrialPlan(o.PlanId)); } /// - /// A user gets one no-card trial, and only for the first organization they create. + /// A user gets one no-card trial, and only for the first organization they own. Being a member of + /// someone else's organization doesn't count. /// - public bool CanStartTrial(User user) + public async Task CanStartTrialAsync(User user) { ArgumentNullException.ThrowIfNull(user); - return _billingOptions.TrialDays > 0 && user.TrialStartedUtc is null && user.OrganizationIds.Count == 0; + if (_billingOptions.TrialDays <= 0 || user.TrialStartedUtc is not null) + return false; + + return (await GetOwnedOrganizationsAsync(user)).Count == 0; } public void StartTrial(Organization organization, User user) @@ -91,8 +95,8 @@ public async Task CanDownGradeAsync(Organization organization, if (String.IsNullOrWhiteSpace(organization?.Id)) return ChangePlanResult.FailWithMessage("Invalid Organization"); - // Ensure the user can't be apart of more than one free or trial plan. - if (_plans.IsFreePlan(plan.Id) && user is not null && (await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray())).Any(o => !String.Equals(o.Id, organization.Id) && _plans.IsFreeOrTrialPlan(o.PlanId))) + // Ensure the user can't own more than one free or trial organization. + if (_plans.IsFreePlan(plan.Id) && user is not null && (await GetOwnedOrganizationsAsync(user)).Any(o => !String.Equals(o.Id, organization.Id) && _plans.IsFreeOrTrialPlan(o.PlanId))) return ChangePlanResult.FailWithMessage("You already have one free account. You are not allowed to create more than one free account."); return new ChangePlanResult { Success = true }; @@ -147,6 +151,19 @@ internal void SetStripeSubscriptionId(Organization organization, string? subscri organization.StripeSubscriptionEventDate = null; } + /// + /// Organizations the user put on their current plan. Organizations have no owner field, so the user who + /// last changed the billing plan (the creator for free and trial organizations) is treated as the owner. + /// + private async Task> GetOwnedOrganizationsAsync(User user) + { + if (user.OrganizationIds.Count == 0) + return []; + + var organizations = await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray()); + return organizations.Where(o => String.Equals(o.BillingChangedByUserId, user.Id, StringComparison.Ordinal)).ToList(); + } + private static string GetOrganizationLockKey(string organizationId) { ArgumentException.ThrowIfNullOrWhiteSpace(organizationId); diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index f7580b3b50..f4ed0bb8ef 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Sockets; using System.Text.Json; using Elastic.Clients.Elasticsearch; diff --git a/src/Exceptionless.Core/Configuration/AppOptions.cs b/src/Exceptionless.Core/Configuration/AppOptions.cs index 1b17b27dad..d5121fcaec 100644 --- a/src/Exceptionless.Core/Configuration/AppOptions.cs +++ b/src/Exceptionless.Core/Configuration/AppOptions.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Text.Json.Serialization; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; diff --git a/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs index a3e7cc11e2..f77ef7d54b 100644 --- a/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs +++ b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Billing; +using Exceptionless.Core.Billing; using Exceptionless.Core.Configuration; using Exceptionless.Core.Mail; using Exceptionless.Core.Messaging.Models; diff --git a/src/Exceptionless.Core/Mail/IMailer.cs b/src/Exceptionless.Core/Mail/IMailer.cs index 831eaee9ea..57105c6478 100644 --- a/src/Exceptionless.Core/Mail/IMailer.cs +++ b/src/Exceptionless.Core/Mail/IMailer.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Models; +using Exceptionless.Core.Models; namespace Exceptionless.Core.Mail; diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 10e57d57b2..3ccafeb4f7 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Extensions; +using System.Globalization; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; using Exceptionless.Core.Queues.Models; @@ -257,7 +258,7 @@ public async Task SendOrganizationTrialEndingAsync(User user, Organization organ var message = new OrganizationTrialEndingEmail( subject, organization.Name, - organization.TrialEndsUtc.Value.ToLongDateString(), + organization.TrialEndsUtc.Value.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture), _appUrls.OrganizationUpgrade(organization.Id)); await QueueMessageAsync(new MailMessage diff --git a/src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs b/src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs new file mode 100644 index 0000000000..336f050596 --- /dev/null +++ b/src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs @@ -0,0 +1,60 @@ +using Exceptionless.Core.Repositories; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Migrations; + +/// +/// Plans no longer limit users or projects. Existing organizations still store their previous plan's limits, +/// so set them to unlimited to match the plans. +/// +public sealed class RemoveOrganizationUserAndProjectLimits : MigrationBase +{ + private const int BatchSize = 100; + private readonly IOrganizationRepository _organizationRepository; + + public RemoveOrganizationUserAndProjectLimits(IOrganizationRepository organizationRepository, ILoggerFactory loggerFactory) : base(loggerFactory) + { + _organizationRepository = organizationRepository; + + MigrationType = MigrationType.VersionedAndResumable; + Version = 11; + } + + public override async Task RunAsync(MigrationContext context) + { + var organizations = await _organizationRepository.FindAsync( + query => query.SortAscending(organization => organization.Id), + options => options + .SoftDeleteMode(SoftDeleteQueryMode.All) + .SearchAfterPaging() + .PageLimit(BatchSize)); + + int migrated = 0; + while (organizations.Documents.Count > 0) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + var organizationsToMigrate = organizations.Documents + .Where(organization => organization.MaxUsers != -1 || organization.MaxProjects != -1) + .ToList(); + if (organizationsToMigrate.Count > 0) + { + foreach (var organization in organizationsToMigrate) + { + organization.MaxUsers = -1; + organization.MaxProjects = -1; + } + + await _organizationRepository.SaveAsync(organizationsToMigrate, options => options.Cache().Notifications(false)); + migrated += organizationsToMigrate.Count; + } + + if (!await organizations.NextPageAsync()) + break; + } + + _logger.LogInformation("Removed user and project limits from {OrganizationCount} organizations", migrated); + } +} diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 4418c05541..8176950548 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using Exceptionless.Core.Attributes; diff --git a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs index 80bfefdf69..c5c99d8b69 100644 --- a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs +++ b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Billing; +using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; diff --git a/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs b/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs index d5e0c73c19..d0b51332ff 100644 --- a/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs +++ b/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs @@ -1,4 +1,4 @@ -namespace Exceptionless.EmailTemplates.Models; +namespace Exceptionless.EmailTemplates.Models; public abstract record EmailTemplate(string Subject, EmailAction? Action = null, string? Preview = null); diff --git a/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs b/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs index 7d5233e960..c280fce7bb 100644 --- a/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs +++ b/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs @@ -1,4 +1,4 @@ -using Exceptionless.EmailTemplates.Components; +using Exceptionless.EmailTemplates.Components; using Exceptionless.EmailTemplates.Models; using Exceptionless.EmailTemplates.Templates; using Microsoft.AspNetCore.Components; diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index cc32ce120f..8b8a72b66e 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -1,4 +1,4 @@ -namespace Exceptionless.Job; +namespace Exceptionless.Job; public class JobRunnerOptions { diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 40c5cfc6f6..4bf440fda2 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using Exceptionless.Core; using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; diff --git a/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs index 06d7bafa01..5c6d367fa5 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Authorization; diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 171207f3a0..59ce98df29 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Messaging.Models; diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index d3de4f1fef..0ac22f5295 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -684,12 +684,13 @@ await messagePublisher.PublishAsync(new UserMembershipChanged if (!currentUser.IsEmailAddressVerified) return Result.Forbidden("Please verify your email address before inviting users."); - int dailyLimit = plans.IsFreeOrTrialPlan(organization.PlanId) + bool isFreeOrTrial = plans.IsFreeOrTrialPlan(organization.PlanId); + int dailyLimit = isFreeOrTrial ? options.BillingOptions.FreeInviteDailyLimit : options.BillingOptions.PaidInviteDailyLimit; var utcNow = timeProvider.GetUtcNow().UtcDateTime; - string cacheKey = $"user:{currentUser.Id}:organization-invites:{utcNow:yyyyMMdd}"; + string cacheKey = $"user:{currentUser.Id}:organization-invites:{(isFreeOrTrial ? "free" : "paid")}:{utcNow:yyyyMMdd}"; long invites = await cacheClient.IncrementAsync(cacheKey, 1, utcNow.Date.AddDays(1)); if (invites <= dailyLimit) return null; @@ -857,7 +858,7 @@ private async Task AddModelAsync(Organization value, HttpContext h var user = GetCurrentUser(httpContext); if (!options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin)) billingManager.ApplyBillingPlan(value, plans.UnlimitedPlan, user); - else if (billingManager.CanStartTrial(user)) + else if (await billingManager.CanStartTrialAsync(user)) billingManager.StartTrial(value, user); else billingManager.ApplyBillingPlan(value, plans.FreePlan, user); diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 8ea9ff24fc..a810ed1d48 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; diff --git a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs index 69cf6258ed..d1e5dfd345 100644 --- a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; @@ -13,7 +13,8 @@ public sealed class AssistantAccessService( AppOptions appOptions, BillingPlans billingPlans, IOrganizationRepository organizationRepository, - SystemSettingsService systemSettingsService) + SystemSettingsService systemSettingsService, + TimeProvider? timeProvider = null) { public async Task GetAccessAsync(HttpRequest request, string? organizationId) { @@ -41,11 +42,18 @@ public async Task GetAccessAsync(HttpRequest request, s if (appOptions.AppMode == AppMode.Development) return AssistantAccessDecision.Available(billingPlans.UnlimitedPlan.Assistant!); - var decision = EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); - if (decision.HasAccess && billingPlans.IsTrialPlan(organization.PlanId) && !request.GetUser().IsEmailAddressVerified) + if (!billingPlans.IsTrialPlan(organization.PlanId)) + return EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); + + // Don't rely on the expiration job having run: an ended trial no longer includes Exie. + var utcNow = (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime; + if (organization.TrialEndsUtc is null || organization.TrialEndsUtc <= utcNow) + return EvaluatePlan(planOptions: null, billingPlans.MediumPlan.Id); + + if (!request.GetUser().IsEmailAddressVerified) return AssistantAccessDecision.EmailVerificationRequired("Verify your email address to use Exie during your trial."); - return decision; + return EvaluatePlan(billingPlans.TrialPlan.Assistant, billingPlans.MediumPlan.Id); } internal static AssistantAccessDecision? EvaluateConfiguration(AppOptions appOptions, bool? enabled = null) diff --git a/src/Exceptionless.Web/Assistant/AssistantModels.cs b/src/Exceptionless.Web/Assistant/AssistantModels.cs index 55eac9d4a7..61068200d0 100644 --- a/src/Exceptionless.Web/Assistant/AssistantModels.cs +++ b/src/Exceptionless.Web/Assistant/AssistantModels.cs @@ -1,4 +1,4 @@ -namespace Exceptionless.Web.Assistant; +namespace Exceptionless.Web.Assistant; public sealed record AssistantChatRequest( IReadOnlyCollection Messages, diff --git a/src/Exceptionless.Web/Assistant/AssistantService.cs b/src/Exceptionless.Web/Assistant/AssistantService.cs index dfe53d8e4e..0f49d98234 100644 --- a/src/Exceptionless.Web/Assistant/AssistantService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantService.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text; diff --git a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs index bbee5bd934..b37cb8697f 100644 --- a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; @@ -187,7 +187,11 @@ public async Task StartProviderRequestAsync(str { var reservation = await RecordProviderRequestStartedAsync(organizationId, providerInputCharacters); if (planOptions is { SharedDailyCostLimitKey: not null, MaximumSharedDailyCostUsd: > 0 }) + { + // Count the estimate immediately so concurrent turns can't all pass the shared limit before any cost lands. reservation = reservation with { SharedDailyCostLimitKey = planOptions.SharedDailyCostLimitKey }; + await IncrementSharedDailyCostAsync(reservation, reservation.CostInMicrodollars); + } return new AssistantProviderUsageLifecycle(this, organizationId, reservation); } @@ -252,6 +256,8 @@ await UpdateProviderUsageCacheWithInitializationAsync( costInMicrodollars: 0, reservation, reverse: false); + + await IncrementSharedDailyCostAsync(reservation, -reservation.CostInMicrodollars); } private async Task RecordProviderUsageAsync( @@ -311,11 +317,24 @@ await UpdateProviderUsageCacheWithInitializationAsync( throw; } - if (reservation?.SharedDailyCostLimitKey is { } sharedDailyCostLimitKey && costInMicrodollars > 0) - { - var day = GetDayWindow(usageDate); - await _cache.IncrementAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id), costInMicrodollars, day.ResetAtUtc.UtcDateTime); - } + if (reservation is not null) + await IncrementSharedDailyCostAsync(reservation, costInMicrodollars - reservation.CostInMicrodollars); + } + + /// + /// Adjusts the shared daily cost counter for a reservation's day. The estimate is added when the provider + /// request starts, then replaced by the actual cost or removed when the reservation is released. + /// + private Task IncrementSharedDailyCostAsync(AssistantProviderReservation reservation, long costInMicrodollars) + { + if (reservation.SharedDailyCostLimitKey is not { } sharedDailyCostLimitKey || costInMicrodollars == 0) + return Task.CompletedTask; + + var usageDate = reservation.UsageDateUtc is { } date + ? new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)) + : timeProvider.GetUtcNow(); + var day = GetDayWindow(usageDate); + return _cache.IncrementAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id), costInMicrodollars, day.ResetAtUtc.UtcDateTime); } private async Task GetSharedDailyCostDecisionAsync(AssistantPlanOptions planOptions, DateTimeOffset now) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts index 4dd8cd5bd7..cb15c69c4b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.stories.ts @@ -222,6 +222,38 @@ export const ExieUpgrade: Story = { name: 'Exie upgrade to Medium' }; +/** Trial org — shows days left, preselects Medium, and notes that billing starts today. */ +export const Trial: Story = { + args: { + organization: makeOrg({ + has_premium_features: true, + max_events_per_month: 75000, + max_projects: -1, + max_users: -1, + plan_description: 'Free Trial', + plan_id: 'EX_TRIAL', + plan_name: 'Trial', + retention_days: 90, + trial_ends_utc: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString() + }), + plans: [ + ...MOCK_PLANS, + { + description: 'Free Trial', + has_premium_features: true, + id: 'EX_TRIAL', + is_hidden: false, + max_events_per_month: 75000, + max_projects: -1, + max_users: -1, + name: 'Trial', + price: 0, + retention_days: 90 + } + ] + } +}; + /** Paid Small monthly org selects a different paid plan (Large monthly). */ export const ChangePlan: Story = { args: { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte index 048fc930cb..12a7547030 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog.svelte @@ -494,7 +494,7 @@ if (isTrialCurrent) { return { isFree: true as const, - label: `Free trial · ${trialDaysRemaining === 1 ? '1 day' : `${trialDaysRemaining} days`} left`, + label: trialDaysRemaining > 0 ? `Free trial · ${trialDaysRemaining === 1 ? '1 day' : `${trialDaysRemaining} days`} left` : 'Free trial · ended', period: '', price: 0 }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte index 37db3e0cce..e3d6504fb0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte @@ -53,6 +53,9 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte index f6f93401ee..11a5ff1de2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte @@ -18,9 +18,17 @@ - {daysLabel} left in the {name} free trial. - - showUpgradeDialog(organizationId, 'Upgrade to keep Exie, alerts, integrations, and 90-day retention.')}>Upgrade now - to keep Exie, alerts, integrations, and 90-day retention when your trial ends. - + {#if daysRemaining > 0} + {daysLabel} left in the {name} free trial. + + showUpgradeDialog(organizationId, 'Upgrade to keep Exie, alerts, integrations, and 90-day retention.')}>Upgrade now + to keep Exie, alerts, integrations, and 90-day retention when your trial ends. + + {:else} + The {name} free trial has ended. + + showUpgradeDialog(organizationId, 'Upgrade to keep Exie, alerts, integrations, and 90-day retention.')}>Upgrade now + to keep Exie, alerts, integrations, and 90-day retention. This organization is moving to the Free plan. + + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index c487ac3da3..d00cfa7ef0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -142,7 +142,7 @@ {:else if requiresPremiumUpgrade} - {:else if isTrialPlan && trialDaysRemaining > 0} + {:else if isTrialPlan} {:else if isFreePlan} (() => SendRequestAsAsync(r => r - .AsTestOrganizationUser() + .BasicAuthorization(user.EmailAddress, password) .Post() .AppendPath("organizations") - .Content(new NewOrganization { Name = "Existing Member Organization" }) + .Content(new NewOrganization { Name = "Existing Owner Organization" }) .StatusCodeShouldBeCreated() )); @@ -2817,6 +2824,31 @@ public async Task PostAsync_UserWithExistingOrganization_GetsFreePlan() Assert.Equal(_plans.FreePlan.Id, organizationView.PlanId); } + [Fact] + public async Task PostAsync_MemberInvitedToSomeoneElsesTrial_StartsOwnTrial() + { + // Arrange + var owner = await CreateUserAsync("trial-team-owner@exceptionless.test", "Owner password", isEmailAddressVerified: true); + var trialOrganization = await CreateTrialOrganizationAsync(owner); + const string password = "Member password"; + var member = await CreateUserAsync("trial-team-member@exceptionless.test", password, isEmailAddressVerified: true); + member.OrganizationIds.Add(trialOrganization.Id); + await _userRepository.SaveAsync(member, o => o.ImmediateConsistency()); + + // Act + var organizationView = await WithBillingEnabledAsync(() => SendRequestAsAsync(r => r + .BasicAuthorization(member.EmailAddress, password) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Member Organization" }) + .StatusCodeShouldBeCreated() + )); + + // Assert + Assert.NotNull(organizationView); + Assert.Equal(_plans.TrialPlan.Id, organizationView.PlanId); + } + [Fact] public async Task ChangePlanAsync_TrialToPaidPlan_StartsBillingAndEndsTrial() { @@ -2938,10 +2970,15 @@ private Task CreateUserAsync(string email, string password, bool isEmailAd return _userRepository.AddAsync(user, o => o.ImmediateConsistency()); } - private async Task CreateTrialOrganizationAsync(User user) + private Task CreateTrialOrganizationAsync(User user) { var organization = new Organization { Name = "Trial Organization" }; _billingManager.StartTrial(organization, user); + return AddMemberOrganizationAsync(user, organization); + } + + private async Task AddMemberOrganizationAsync(User user, Organization organization) + { organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency()); user.OrganizationIds.Add(organization.Id); diff --git a/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs index 73370b8765..42ef95b194 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Configuration; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; diff --git a/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs b/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs index bbba25e8a6..65f81b2ab6 100644 --- a/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs +++ b/tests/Exceptionless.Tests/Assistant/AssistantAccessServiceTests.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -242,14 +242,22 @@ public async Task GetAccessAsync_Development_BypassesPlanOnlyAfterOrganizationVa } [Theory] - [InlineData(false, false, AssistantAccessReason.EmailVerificationRequired)] - [InlineData(true, true, AssistantAccessReason.Available)] - public async Task GetAccessAsync_TrialPlan_RequiresVerifiedEmail(bool isEmailAddressVerified, bool expectedAccess, AssistantAccessReason expectedReason) + [InlineData(false, 3, false, AssistantAccessReason.EmailVerificationRequired)] + [InlineData(true, 3, true, AssistantAccessReason.Available)] + [InlineData(true, -1, false, AssistantAccessReason.UpgradeRequired)] + [InlineData(true, null, false, AssistantAccessReason.UpgradeRequired)] + public async Task GetAccessAsync_TrialPlan_RequiresVerifiedEmailAndActiveTrial(bool isEmailAddressVerified, int? trialDaysLeft, bool expectedAccess, AssistantAccessReason expectedReason) { // Arrange var options = CreateOptions(new Dictionary { ["Assistant:ApiKey"] = "test-key" }); var billingPlans = new BillingPlans(options); - var organization = new Organization { Id = TestConstants.OrganizationId, Name = "Trial", PlanId = billingPlans.TrialPlan.Id }; + var organization = new Organization + { + Id = TestConstants.OrganizationId, + Name = "Trial", + PlanId = billingPlans.TrialPlan.Id, + TrialEndsUtc = trialDaysLeft is null ? null : DateTime.UtcNow.AddDays(trialDaysLeft.Value) + }; var repository = new Mock(); repository.Setup(repository => repository.GetByIdAsync(organization.Id, It.IsAny>())) .ReturnsAsync(organization); @@ -263,8 +271,8 @@ public async Task GetAccessAsync_TrialPlan_RequiresVerifiedEmail(bool isEmailAdd // Assert Assert.Equal(expectedAccess, access.HasAccess); Assert.Equal(expectedReason, access.Reason); - Assert.False(access.UpgradeRequired); - Assert.Equal(!isEmailAddressVerified, access.ToResponse().EmailVerificationRequired); + Assert.Equal(expectedReason == AssistantAccessReason.UpgradeRequired, access.UpgradeRequired); + Assert.Equal(expectedReason == AssistantAccessReason.EmailVerificationRequired, access.ToResponse().EmailVerificationRequired); } private static void AssertPlan(AssistantPlanOptions? options, int concurrentTurns, int turnsPerMinute, long monthlyTokens, decimal monthlyCost) diff --git a/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs b/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs index a3e35b2279..556a1f983d 100644 --- a/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs +++ b/tests/Exceptionless.Tests/Assistant/AssistantUsageServiceTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Exceptionless.Core; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; @@ -70,6 +70,27 @@ public async Task TryStartTurnAsync_SharedDailyCostLimitReached_BlocksEveryOrgan Assert.True(nextDay.Allowed); } + [Fact] + public async Task StartProviderRequestAsync_SharedDailyCostLimit_CountsInFlightEstimateUntilReleased() + { + // Arrange + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 8, 2, 12, 0, 0, TimeSpan.Zero)); + using var cache = CreateCache(timeProvider); + var service = CreateService(cache, CreateOptions(), timeProvider); + var sharedPlanOptions = CreatePlanOptions() with { SharedDailyCostLimitKey = "trial", MaximumSharedDailyCostUsd = 0.000001m }; + + // Act + var providerRequest = await service.StartProviderRequestAsync("first-organization", 1_000, sharedPlanOptions); + await using var whileInFlight = await service.TryStartTurnAsync("second-organization", sharedPlanOptions); + await providerRequest.DisposeAsync(); + await using var afterRelease = await service.TryStartTurnAsync("second-organization", sharedPlanOptions); + + // Assert + Assert.False(whileInFlight.Allowed); + Assert.Equal(AssistantUsageLimit.SharedDailyCost, whileInFlight.Limit); + Assert.True(afterRelease.Allowed); + } + [Fact] public async Task TryStartTurnAsync_ConcurrentRequests_AtomicallyReservesConfiguredLimit() { diff --git a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs index 98be5e4dee..31254cd889 100644 --- a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs @@ -93,4 +93,23 @@ public async Task RunAsync_TrialLifecycle_SendsOneReminderThenMovesToFreePlan() Assert.NotNull(paidOrganization); Assert.Equal(BillingPlans.MediumPlan.Id, paidOrganization.PlanId); } + + [Fact] + public async Task RunAsync_TrialWithoutEndDate_MovesToFreePlan() + { + // Arrange + var organization = await OrganizationRepository.GetByIdAsync(TrialOrganizationId); + Assert.NotNull(organization); + organization.TrialEndsUtc = null; + await OrganizationRepository.SaveAsync(organization, options => options.ImmediateConsistency()); + + // Act + await Job.RunAsync(TestCancellationToken); + + // Assert + var expiredOrganization = await OrganizationRepository.GetByIdAsync(TrialOrganizationId); + Assert.NotNull(expiredOrganization); + Assert.Equal(BillingPlans.FreePlan.Id, expiredOrganization.PlanId); + Assert.Empty(Mailer.OrganizationTrialEndingCalls); + } } diff --git a/tests/Exceptionless.Tests/Mail/CountingMailer.cs b/tests/Exceptionless.Tests/Mail/CountingMailer.cs index 878f8df3f9..43ad1073a0 100644 --- a/tests/Exceptionless.Tests/Mail/CountingMailer.cs +++ b/tests/Exceptionless.Tests/Mail/CountingMailer.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Mail; +using Exceptionless.Core.Mail; using Exceptionless.Core.Models; namespace Exceptionless.Tests.Mail; diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 841a0232fd..a4f69eb77f 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Text.Json; using System.Text.RegularExpressions; using Exceptionless.Core; diff --git a/tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs new file mode 100644 index 0000000000..1c76d49bb6 --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs @@ -0,0 +1,50 @@ +using Exceptionless.Core.Migrations; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Lock; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Foundatio.Utility; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class RemoveOrganizationUserAndProjectLimitsMigrationTests : IntegrationTestsBase +{ + private readonly IOrganizationRepository _organizationRepository; + + public RemoveOrganizationUserAndProjectLimitsMigrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _organizationRepository = GetService(); + } + + protected override void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(EmptyLock.Empty); + base.RegisterServices(services); + } + + [Fact] + public async Task RunAsync_OrganizationsWithLegacyLimits_BecomeUnlimited() + { + // Arrange + var limited = new Organization { Name = "Legacy Free Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 1, MaxProjects = 1 }; + var deleted = new Organization { Name = "Deleted Small Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 10, MaxProjects = 5, IsDeleted = true }; + await _organizationRepository.AddAsync([limited, deleted], options => options.ImmediateConsistency()); + + // Act + var migration = GetService(); + await migration.RunAsync(new MigrationContext(GetService(), _logger, TestCancellationToken)); + + // Assert + await RefreshDataAsync(); + foreach (string id in new[] { limited.Id, deleted.Id }) + { + var organization = await _organizationRepository.GetByIdAsync(id, options => options.SoftDeleteMode(SoftDeleteQueryMode.All)); + Assert.NotNull(organization); + Assert.Equal(-1, organization.MaxUsers); + Assert.Equal(-1, organization.MaxProjects); + } + } +} From 0a31a3c7c5031829ccd24a3efcb792084f7e0df6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 08:43:10 -0500 Subject: [PATCH 03/35] Keep the Free plan banner from breaking when limits are missing The banner called toLocaleString() on max_events_per_month, which threw when an organization response omitted it and broke the app shell (caught by the navbar-layout E2E test, whose mocked organization has no limits). Co-Authored-By: Claude Opus 5.5 --- .../notifications/free-plan-notification.svelte | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte index 2a09d39196..3e036fbbb9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte @@ -6,14 +6,16 @@ import { showUpgradeDialog } from '#features/billing/upgrade-required.svelte.ts'; interface Props extends NotificationProps { - maxEventsPerMonth: number; + maxEventsPerMonth?: number; name: string; organizationId: string; - retentionDays: number; + retentionDays?: number; trialEnded?: boolean; } let { maxEventsPerMonth, name, organizationId, retentionDays, trialEnded = false, ...restProps }: Props = $props(); + + const hasLimits = $derived(typeof maxEventsPerMonth === 'number' && typeof retentionDays === 'number'); @@ -25,7 +27,11 @@ {/if} - The Free plan includes {maxEventsPerMonth.toLocaleString()} events per month and {retentionDays} days of data retention, without Exie, alerts, or integrations. + {#if hasLimits} + The Free plan includes {maxEventsPerMonth?.toLocaleString()} events per month and {retentionDays} days of data retention, without Exie, alerts, or integrations. + {:else} + The Free plan doesn't include Exie, alerts, or integrations. + {/if} showUpgradeDialog(organizationId, 'Upgrade to enable premium features and extra storage.')}>Upgrade now to get them back. From 3e67fd79bbb72256d368ce9f57cda9a6e467d12d Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 08:49:24 -0500 Subject: [PATCH 04/35] Only say a trial ended when the organization never subscribed An organization that converted from a trial keeps TrialEndsUtc, so a later downgrade to Free showed "Your trial has ended". Show that only when the organization has a trial date and never subscribed. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/features/billing/trial.test.ts | 13 ++++++++++++- .../ClientApp/src/lib/features/billing/trial.ts | 12 ++++++++++++ .../components/organization-notifications.svelte | 4 ++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts index 47d1bee34f..7dff7acf3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getTrialDaysRemaining } from './trial.ts'; +import { getTrialDaysRemaining, hasTrialEndedWithoutSubscribing } from './trial.ts'; describe('getTrialDaysRemaining', () => { const now = new Date('2026-10-22T12:00:00Z'); @@ -15,3 +15,14 @@ describe('getTrialDaysRemaining', () => { expect(getTrialDaysRemaining(trialEndsUtc, now)).toBe(expected); }); }); + +describe('hasTrialEndedWithoutSubscribing', () => { + it.each([ + ['a trial that ended without subscribing', { subscribe_date: null, trial_ends_utc: '2026-10-21T12:00:00Z' }, true], + ['a trial that converted and later downgraded', { subscribe_date: '2026-10-20T12:00:00Z', trial_ends_utc: '2026-10-21T12:00:00Z' }, false], + ['a legacy empty subscribe date', { subscribe_date: '0001-01-01T00:00:00', trial_ends_utc: '2026-10-21T12:00:00Z' }, true], + ['an organization that never had a trial', { subscribe_date: null, trial_ends_utc: null }, false] + ])('returns the result for %s', (_, organization, expected) => { + expect(hasTrialEndedWithoutSubscribing(organization)).toBe(expected); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts index 87c814baa7..8b30208a2c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts @@ -16,3 +16,15 @@ export function getTrialDaysRemaining(trialEndsUtc: null | string | undefined, n return Math.max(0, Math.ceil((endsAt - now.getTime()) / MILLISECONDS_PER_DAY)); } + +/** + * Whether a Free organization got there because its trial ended (or was ended early) without ever subscribing. + * Organizations that converted to a paid plan and later downgraded keep their trial date, but didn't just leave a trial. + */ +export function hasTrialEndedWithoutSubscribing(organization: { subscribe_date?: null | string; trial_ends_utc?: null | string }): boolean { + if (!organization.trial_ends_utc) { + return false; + } + + return !organization.subscribe_date || organization.subscribe_date.startsWith('0001-01-01'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index d00cfa7ef0..9bb93e64a5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -27,7 +27,7 @@ import type { NotificationProps } from '#comp/notification/index.ts'; import { FREE_PLAN_ID, TRIAL_PLAN_ID } from '#features/billing/constants.ts'; - import { getTrialDaysRemaining } from '#features/billing/trial.ts'; + import { getTrialDaysRemaining, hasTrialEndedWithoutSubscribing } from '#features/billing/trial.ts'; import { getOrganizationQuery, getOrganizationsQuery } from '#features/organizations/api.svelte.ts'; import { organization as currentOrganizationId } from '#features/organizations/context.svelte.ts'; import { SuspensionCode } from '#features/organizations/models.ts'; @@ -150,7 +150,7 @@ name={organization.name} organizationId={organization.id} retentionDays={organization.retention_days} - trialEnded={!!organization.trial_ends_utc} + trialEnded={hasTrialEndedWithoutSubscribing(organization)} {...restProps} /> {:else if hasNoProjects} From 4cf4254fd2e2a353cfee53b8e6a0267d955d20a0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 09:02:59 -0500 Subject: [PATCH 05/35] Serialize organization creation and track the creating user - Take a per-user lock around organization creation and reload the user inside it, so concurrent requests can't both pass the one-trial and one-free-organization checks - Add Organization.CreatedByUserId and use it for ownership, falling back to BillingChangedByUserId for existing organizations, so a member changing the plan doesn't transfer ownership Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 12 ++- src/Exceptionless.Core/Models/Organization.cs | 6 ++ .../Api/Handlers/OrganizationHandler.cs | 25 ++++-- .../Endpoints/OrganizationEndpointTests.cs | 83 ++++++++++++++++++- 4 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 559c7b9d89..a03cf44029 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -28,6 +28,12 @@ public BillingManager(ILockProvider lockProvider, IOrganizationRepository organi public Task AcquireOrganizationLockAsync(string organizationId) => _lockProvider.AcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.FromSeconds(30)); + public Task AcquireUserOrganizationCreationLockAsync(string userId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + return _lockProvider.AcquireAsync($"User:{userId}:create-organization", TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30)); + } + public Task TryAcquireOrganizationLockAsync(string organizationId) => _lockProvider.TryAcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.Zero); @@ -152,8 +158,8 @@ internal void SetStripeSubscriptionId(Organization organization, string? subscri } /// - /// Organizations the user put on their current plan. Organizations have no owner field, so the user who - /// last changed the billing plan (the creator for free and trial organizations) is treated as the owner. + /// Organizations the user created. Organizations created before + /// existed fall back to the user who last changed the billing plan. /// private async Task> GetOwnedOrganizationsAsync(User user) { @@ -161,7 +167,7 @@ private async Task> GetOwnedOrganizationsAsync return []; var organizations = await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray()); - return organizations.Where(o => String.Equals(o.BillingChangedByUserId, user.Id, StringComparison.Ordinal)).ToList(); + return organizations.Where(o => String.Equals(o.CreatedByUserId ?? o.BillingChangedByUserId, user.Id, StringComparison.Ordinal)).ToList(); } private static string GetOrganizationLockKey(string organizationId) diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 8176950548..68b4df9e39 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -77,6 +77,12 @@ public Organization() /// public DateTime? SubscribeDate { get; set; } + /// + /// User who created the organization. Used to enforce per-owner plan rules such as one free organization. + /// + [ObjectId] + public string? CreatedByUserId { get; set; } + /// /// Date the organization's no-card trial ends. Kept after the trial ends so the app can tell the /// organization its trial is over. diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 0ac22f5295..b62195c450 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -110,11 +110,20 @@ public async Task> Handle(CreateOrganization message) return Result.BadRequest("Organization value is required."); var model = mapper.MapToOrganization(message.Organization); - var error = await CanAddAsync(model, message.Context); + + // Serialize creation per user and reload the user so concurrent requests can't both pass the + // one-free-organization and one-trial checks. + string userId = GetCurrentUser(message.Context).Id; + await using var userLock = await billingManager.AcquireUserOrganizationCreationLockAsync(userId); + var user = await userRepository.GetByIdAsync(userId, o => o.Cache(false)); + if (user is null) + return Result.Unauthorized("User not found."); + + var error = await CanAddAsync(model, user); if (error is not null) return error; - model = await AddModelAsync(model, message.Context); + model = await AddModelAsync(model, user); var viewModel = mapper.MapToViewOrganization(model); await AfterResultMapAsync([viewModel]); string location = linkGenerator.GetUriByName(message.Context, "GetOrganizationById", new { id = model.Id }) @@ -842,20 +851,20 @@ private async Task MapToViewAsync(Organization model) return viewModel; } - private async Task?> CanAddAsync(Organization value, HttpContext httpContext) + private async Task?> CanAddAsync(Organization value, User user) { if (String.IsNullOrEmpty(value.Name)) return Result.BadRequest("Organization name is required."); - if (!await billingManager.CanAddOrganizationAsync(GetCurrentUser(httpContext))) + if (!await billingManager.CanAddOrganizationAsync(user)) return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.PlanLimit, "Please upgrade your plan to add an additional organization.")); return null; } - private async Task AddModelAsync(Organization value, HttpContext httpContext) + private async Task AddModelAsync(Organization value, User user) { - var user = GetCurrentUser(httpContext); + value.CreatedByUserId = user.Id; if (!options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin)) billingManager.ApplyBillingPlan(value, plans.UnlimitedPlan, user); else if (await billingManager.CanStartTrialAsync(user)) @@ -863,10 +872,10 @@ private async Task AddModelAsync(Organization value, HttpContext h else billingManager.ApplyBillingPlan(value, plans.FreePlan, user); - var organization = await repository.AddAsync(value, o => o.Cache()); + var organization = await repository.AddAsync(value, o => o.ImmediateConsistency().Cache()); user.OrganizationIds.Add(organization.Id); - await userRepository.SaveAsync(user, o => o.Cache()); + await userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); await messagePublisher.PublishAsync(new UserMembershipChanged { UserId = user.Id, diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index eb8ced52ae..caa825973c 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -2778,6 +2778,87 @@ public async Task PostAsync_FirstOrganizationWithBillingEnabled_StartsTrialAndAl Assert.NotNull(updatedUser.TrialStartedUtc); } + [Fact] + public async Task PostAsync_ConcurrentFirstOrganizations_StartsOnlyOneTrial() + { + // Arrange + const string password = "Trial password"; + var user = await CreateUserAsync("concurrent-trial@exceptionless.test", password, isEmailAddressVerified: true); + + // Act + var statusCodes = await WithBillingEnabledAsync(async () => + { + using var client = CreateHttpClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{user.EmailAddress}:{password}"))); + var responses = await Task.WhenAll(Enumerable.Range(0, 3).Select(index => client.PostAsync("organizations", + System.Net.Http.Json.JsonContent.Create(new { name = $"Concurrent Organization {index}" }), TestCancellationToken))); + + return responses.Select(response => response.StatusCode).ToList(); + }); + + // Assert + Assert.Single(statusCodes, HttpStatusCode.Created); + Assert.Equal(2, statusCodes.Count(statusCode => statusCode == HttpStatusCode.UpgradeRequired)); + + await RefreshDataAsync(); + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + var organizationId = Assert.Single(updatedUser.OrganizationIds); + var organization = await _organizationRepository.GetByIdAsync(organizationId); + Assert.NotNull(organization); + Assert.Equal(_plans.TrialPlan.Id, organization.PlanId); + Assert.Equal(user.Id, organization.CreatedByUserId); + } + + [Fact] + public async Task PostAsync_AfterMemberDowngradesCreatorsTrial_CreatorStillOwnsFreeOrganization() + { + // Arrange + const string ownerPassword = "Owner password"; + var owner = await CreateUserAsync("downgraded-trial-owner@exceptionless.test", ownerPassword, isEmailAddressVerified: true); + var trialOrganization = await CreateTrialOrganizationAsync(owner); + const string memberPassword = "Member password"; + var member = await CreateUserAsync("downgrading-member@exceptionless.test", memberPassword, isEmailAddressVerified: true); + member.OrganizationIds.Add(trialOrganization.Id); + await _userRepository.SaveAsync(member, o => o.ImmediateConsistency()); + + // Act + var (ownerStatusCode, memberOrganization) = await WithBillingEnabledAsync(async () => + { + await SendRequestAsync(r => r + .BasicAuthorization(member.EmailAddress, memberPassword) + .Post() + .AppendPaths("organizations", trialOrganization.Id, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeOk() + ); + + var ownerResponse = await SendRequestAsync(r => r + .BasicAuthorization(owner.EmailAddress, ownerPassword) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Second Owner Organization" }) + .StatusCodeShouldBeUpgradeRequired() + ); + + var created = await SendRequestAsAsync(r => r + .BasicAuthorization(member.EmailAddress, memberPassword) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Member Organization" }) + .StatusCodeShouldBeCreated() + ); + + return (ownerResponse.StatusCode, created); + }); + + // Assert + Assert.Equal(HttpStatusCode.UpgradeRequired, ownerStatusCode); + Assert.NotNull(memberOrganization); + Assert.Equal(_plans.TrialPlan.Id, memberOrganization.PlanId); + } + [Fact] public async Task PostAsync_UserWhoAlreadyStartedTrial_GetsFreePlan() { @@ -2972,7 +3053,7 @@ private Task CreateUserAsync(string email, string password, bool isEmailAd private Task CreateTrialOrganizationAsync(User user) { - var organization = new Organization { Name = "Trial Organization" }; + var organization = new Organization { Name = "Trial Organization", CreatedByUserId = user.Id }; _billingManager.StartTrial(organization, user); return AddMemberOrganizationAsync(user, organization); } From 6614a3a953438bc48e295414ef0af93d31befda9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 09:17:08 -0500 Subject: [PATCH 06/35] Find owned organizations by creator and publish invite responses - Query owned organizations by CreatedByUserId (now mapped), so leaving an organization doesn't free up its owner's free-organization slot - Check Free downgrades against the organization's owner, not the member making the change - Map created_by_user_id in migration 11 (renamed UpdateOrganizationsForTrials) - Document the invite endpoint's 403 and 429 responses and drop the removed 426 in the endpoint metadata and OpenAPI snapshot Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 43 ++++++++++----- ...cs => 011_UpdateOrganizationsForTrials.cs} | 21 ++++++-- .../Indexes/OrganizationIndex.cs | 1 + .../Api/Endpoints/OrganizationEndpoints.cs | 6 ++- .../Exceptionless.Tests/Api/Data/openapi.json | 14 ++++- .../Endpoints/OrganizationEndpointTests.cs | 53 +++++++++++++++++++ ...teOrganizationsForTrialsMigrationTests.cs} | 16 +++--- 7 files changed, 128 insertions(+), 26 deletions(-) rename src/Exceptionless.Core/Migrations/{011_RemoveOrganizationUserAndProjectLimits.cs => 011_UpdateOrganizationsForTrials.cs} (60%) rename tests/Exceptionless.Tests/Migrations/{RemoveOrganizationUserAndProjectLimitsMigrationTests.cs => UpdateOrganizationsForTrialsMigrationTests.cs} (70%) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index a03cf44029..31fa9f2711 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -4,6 +4,7 @@ using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; using Foundatio.Lock; +using Foundatio.Repositories; namespace Exceptionless.Core.Billing; @@ -12,14 +13,16 @@ public class BillingManager private static readonly TimeSpan BillingLockDuration = TimeSpan.FromMinutes(5); private readonly ILockProvider _lockProvider; private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; private readonly BillingPlans _plans; private readonly BillingOptions _billingOptions; private readonly TimeProvider _timeProvider; - public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, BillingPlans plans, AppOptions appOptions, TimeProvider timeProvider) + public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, IUserRepository userRepository, BillingPlans plans, AppOptions appOptions, TimeProvider timeProvider) { _lockProvider = lockProvider; _organizationRepository = organizationRepository; + _userRepository = userRepository; _plans = plans; _billingOptions = appOptions.BillingOptions; _timeProvider = timeProvider; @@ -42,7 +45,7 @@ public async Task CanAddOrganizationAsync(User? user) if (user is null) return false; - var ownedOrganizations = await GetOwnedOrganizationsAsync(user); + var ownedOrganizations = await GetOwnedOrganizationsAsync(user.Id, user.OrganizationIds); return !ownedOrganizations.Any(o => _plans.IsFreeOrTrialPlan(o.PlanId)); } @@ -56,7 +59,7 @@ public async Task CanStartTrialAsync(User user) if (_billingOptions.TrialDays <= 0 || user.TrialStartedUtc is not null) return false; - return (await GetOwnedOrganizationsAsync(user)).Count == 0; + return (await GetOwnedOrganizationsAsync(user.Id, user.OrganizationIds)).Count == 0; } public void StartTrial(Organization organization, User user) @@ -101,8 +104,8 @@ public async Task CanDownGradeAsync(Organization organization, if (String.IsNullOrWhiteSpace(organization?.Id)) return ChangePlanResult.FailWithMessage("Invalid Organization"); - // Ensure the user can't own more than one free or trial organization. - if (_plans.IsFreePlan(plan.Id) && user is not null && (await GetOwnedOrganizationsAsync(user)).Any(o => !String.Equals(o.Id, organization.Id) && _plans.IsFreeOrTrialPlan(o.PlanId))) + // Ensure the organization's owner can't own more than one free or trial organization, no matter which member downgrades it. + if (_plans.IsFreePlan(plan.Id) && user is not null && await OwnerHasOtherFreeOrTrialOrganizationAsync(organization, user)) return ChangePlanResult.FailWithMessage("You already have one free account. You are not allowed to create more than one free account."); return new ChangePlanResult { Success = true }; @@ -157,17 +160,33 @@ internal void SetStripeSubscriptionId(Organization organization, string? subscri organization.StripeSubscriptionEventDate = null; } + private async Task OwnerHasOtherFreeOrTrialOrganizationAsync(Organization organization, User actingUser) + { + string ownerId = organization.CreatedByUserId ?? organization.BillingChangedByUserId ?? actingUser.Id; + var ownerOrganizationIds = String.Equals(ownerId, actingUser.Id, StringComparison.Ordinal) + ? actingUser.OrganizationIds + : (await _userRepository.GetByIdAsync(ownerId))?.OrganizationIds ?? new HashSet(); + + var ownedOrganizations = await GetOwnedOrganizationsAsync(ownerId, ownerOrganizationIds); + return ownedOrganizations.Any(o => !String.Equals(o.Id, organization.Id, StringComparison.Ordinal) && _plans.IsFreeOrTrialPlan(o.PlanId)); + } + /// - /// Organizations the user created. Organizations created before - /// existed fall back to the user who last changed the billing plan. + /// Organizations the user created, including ones they're no longer a member of. Organizations created before + /// existed fall back to the member who last changed the billing plan. /// - private async Task> GetOwnedOrganizationsAsync(User user) + private async Task> GetOwnedOrganizationsAsync(string userId, ICollection membershipOrganizationIds) { - if (user.OrganizationIds.Count == 0) - return []; + var createdOrganizations = await _organizationRepository.FindAsync(q => q.FieldEquals(o => o.CreatedByUserId, userId), o => o.PageLimit(100)); + var ownedOrganizations = createdOrganizations.Documents.ToList(); + + if (membershipOrganizationIds.Count > 0) + { + var memberOrganizations = await _organizationRepository.GetByIdsAsync(membershipOrganizationIds.ToArray()); + ownedOrganizations.AddRange(memberOrganizations.Where(o => o.CreatedByUserId is null && String.Equals(o.BillingChangedByUserId, userId, StringComparison.Ordinal))); + } - var organizations = await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray()); - return organizations.Where(o => String.Equals(o.CreatedByUserId ?? o.BillingChangedByUserId, user.Id, StringComparison.Ordinal)).ToList(); + return ownedOrganizations.DistinctBy(o => o.Id).ToList(); } private static string GetOrganizationLockKey(string organizationId) diff --git a/src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs b/src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs similarity index 60% rename from src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs rename to src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs index 336f050596..be02aa5239 100644 --- a/src/Exceptionless.Core/Migrations/011_RemoveOrganizationUserAndProjectLimits.cs +++ b/src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs @@ -1,22 +1,28 @@ +using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch.Extensions; using Foundatio.Repositories.Migrations; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Migrations; /// -/// Plans no longer limit users or projects. Existing organizations still store their previous plan's limits, -/// so set them to unlimited to match the plans. +/// Prepares organizations for free trials: maps so ownership can be +/// queried, and, because plans no longer limit users or projects, sets the limits stored on existing +/// organizations to unlimited. /// -public sealed class RemoveOrganizationUserAndProjectLimits : MigrationBase +public sealed class UpdateOrganizationsForTrials : MigrationBase { private const int BatchSize = 100; + private readonly ExceptionlessElasticConfiguration _configuration; private readonly IOrganizationRepository _organizationRepository; - public RemoveOrganizationUserAndProjectLimits(IOrganizationRepository organizationRepository, ILoggerFactory loggerFactory) : base(loggerFactory) + public UpdateOrganizationsForTrials(IOrganizationRepository organizationRepository, ExceptionlessElasticConfiguration configuration, ILoggerFactory loggerFactory) : base(loggerFactory) { _organizationRepository = organizationRepository; + _configuration = configuration; MigrationType = MigrationType.VersionedAndResumable; Version = 11; @@ -24,6 +30,13 @@ public RemoveOrganizationUserAndProjectLimits(IOrganizationRepository organizati public override async Task RunAsync(MigrationContext context) { + var mappingResponse = await _configuration.Client.Indices.PutMappingAsync(mapping => mapping + .Indices(_configuration.Organizations.VersionedName) + .Properties(properties => properties.Keyword(organization => organization.CreatedByUserId)), context.CancellationToken); + _logger.LogRequest(mappingResponse); + if (!mappingResponse.IsValidResponse) + throw new InvalidOperationException("Unable to map the organization created by user id."); + var organizations = await _organizationRepository.FindAsync( query => query.SortAscending(organization => organization.Id), options => options diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs index 1e17898890..ddc305a5f6 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs @@ -26,6 +26,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor m .SetupDefaults() .Text(e => e.Name, t => t.AddKeywordField()) .Keyword(e => e.StripeCustomerId) + .Keyword(e => e.CreatedByUserId) .Boolean(e => e.HasPremiumFeatures) .Keyword(e => e.Features) .Keyword(e => e.PlanId) diff --git a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs index 5cd02efbe3..23059d0bba 100644 --- a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs @@ -280,8 +280,9 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute group.MapPost("organizations/{id:objectid}/users/{email:minlength(1)}", async (string id, string email, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>(new OrganizationMessages.AddOrganizationUser(id, email, httpContext))).ToHttpResult(resultMapper)) .Produces() + .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .ProducesProblem(StatusCodes.Status429TooManyRequests) .WithSummary("Add user") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -289,8 +290,9 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute ["email"] = "The email address of the user you wish to add to your organization.", }, ResponseDescriptions = new() { + ["403"] = "Please verify your email address before inviting users.", ["404"] = "The organization was not found.", - ["426"] = "Please upgrade your plan to add an additional user.", + ["429"] = "You've sent the maximum number of invites for today.", } }); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 4501cdc80e..6c2b1aa0f8 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -7196,6 +7196,16 @@ } } }, + "403": { + "description": "Please verify your email address before inviting users.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "404": { "description": "The organization was not found.", "content": { @@ -7206,8 +7216,8 @@ } } }, - "426": { - "description": "Please upgrade your plan to add an additional user.", + "429": { + "description": "You\u0027ve sent the maximum number of invites for today.", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index caa825973c..6a26b57a8c 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -2859,6 +2859,59 @@ await SendRequestAsync(r => r Assert.Equal(_plans.TrialPlan.Id, memberOrganization.PlanId); } + [Fact] + public async Task PostAsync_CreatorWhoLeftTheirTrialOrganization_CannotCreateAnotherFreeOrganization() + { + // Arrange + const string password = "Owner password"; + var owner = await CreateUserAsync("departed-trial-owner@exceptionless.test", password, isEmailAddressVerified: true); + var trialOrganization = await CreateTrialOrganizationAsync(owner); + owner.OrganizationIds.Remove(trialOrganization.Id); + await _userRepository.SaveAsync(owner, o => o.ImmediateConsistency()); + + // Act & Assert + await WithBillingEnabledAsync(() => SendRequestAsync(r => r + .BasicAuthorization(owner.EmailAddress, password) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Another Free Organization" }) + .StatusCodeShouldBeUpgradeRequired() + )); + } + + [Fact] + public async Task ChangePlanAsync_MemberDowngradesOwnersSecondOrganizationToFree_ReturnsFailure() + { + // Arrange + var owner = await CreateUserAsync("two-organization-owner@exceptionless.test", "Owner password", isEmailAddressVerified: true); + var freeOrganization = new Organization { Name = "Owner Free Organization", CreatedByUserId = owner.Id }; + _billingManager.ApplyBillingPlan(freeOrganization, _plans.FreePlan, owner); + await AddMemberOrganizationAsync(owner, freeOrganization); + + var paidOrganization = new Organization { Name = "Owner Paid Organization", CreatedByUserId = owner.Id, StripeCustomerId = "cus_owner", CardLast4 = "4242", SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime }; + _billingManager.ApplyBillingPlan(paidOrganization, _plans.SmallPlan, owner); + paidOrganization = await AddMemberOrganizationAsync(owner, paidOrganization); + + const string memberPassword = "Member password"; + var member = await CreateUserAsync("downgrading-paid-member@exceptionless.test", memberPassword, isEmailAddressVerified: true); + member.OrganizationIds.Add(paidOrganization.Id); + await _userRepository.SaveAsync(member, o => o.ImmediateConsistency()); + + // Act + var result = await WithBillingEnabledAsync(() => SendRequestAsAsync(r => r + .BasicAuthorization(member.EmailAddress, memberPassword) + .Post() + .AppendPaths("organizations", paidOrganization.Id, "change-plan") + .Content(new ChangePlanRequest { PlanId = _plans.FreePlan.Id }) + .StatusCodeShouldBeOk() + )); + + // Assert + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("free account", result.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task PostAsync_UserWhoAlreadyStartedTrial_GetsFreePlan() { diff --git a/tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs similarity index 70% rename from tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs rename to tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs index 1c76d49bb6..a6549af8ed 100644 --- a/tests/Exceptionless.Tests/Migrations/RemoveOrganizationUserAndProjectLimitsMigrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs @@ -1,6 +1,7 @@ using Exceptionless.Core.Migrations; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Tests.Utility; using Foundatio.Lock; using Foundatio.Repositories; using Foundatio.Repositories.Migrations; @@ -9,32 +10,32 @@ namespace Exceptionless.Tests.Migrations; -public sealed class RemoveOrganizationUserAndProjectLimitsMigrationTests : IntegrationTestsBase +public sealed class UpdateOrganizationsForTrialsMigrationTests : IntegrationTestsBase { private readonly IOrganizationRepository _organizationRepository; - public RemoveOrganizationUserAndProjectLimitsMigrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + public UpdateOrganizationsForTrialsMigrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _organizationRepository = GetService(); } protected override void RegisterServices(IServiceCollection services) { - services.AddTransient(); + services.AddTransient(); services.AddSingleton(EmptyLock.Empty); base.RegisterServices(services); } [Fact] - public async Task RunAsync_OrganizationsWithLegacyLimits_BecomeUnlimited() + public async Task RunAsync_OrganizationsWithLegacyLimits_BecomeUnlimitedAndCreatorIsQueryable() { // Arrange - var limited = new Organization { Name = "Legacy Free Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 1, MaxProjects = 1 }; + var limited = new Organization { Name = "Legacy Free Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 1, MaxProjects = 1, CreatedByUserId = TestConstants.UserId }; var deleted = new Organization { Name = "Deleted Small Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 10, MaxProjects = 5, IsDeleted = true }; await _organizationRepository.AddAsync([limited, deleted], options => options.ImmediateConsistency()); // Act - var migration = GetService(); + var migration = GetService(); await migration.RunAsync(new MigrationContext(GetService(), _logger, TestCancellationToken)); // Assert @@ -46,5 +47,8 @@ public async Task RunAsync_OrganizationsWithLegacyLimits_BecomeUnlimited() Assert.Equal(-1, organization.MaxUsers); Assert.Equal(-1, organization.MaxProjects); } + + var created = await _organizationRepository.FindAsync(q => q.FieldEquals(o => o.CreatedByUserId, TestConstants.UserId)); + Assert.Equal(limited.Id, Assert.Single(created.Documents).Id); } } From fa0c62a0d2ffd72a660fe5532b8e64e1683c3631 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 09:25:50 -0500 Subject: [PATCH 07/35] Don't cap ownership checks at one page of organizations Ask for exactly what each check needs instead of loading the first 100 created organizations: a count for "owns any organization", and a query filtered to the free and trial plans for the one-free-organization rule. Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 51 +++++++++++++------ .../Endpoints/OrganizationEndpointTests.cs | 27 ++++++++++ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 31fa9f2711..f3ab3908e9 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -45,8 +45,7 @@ public async Task CanAddOrganizationAsync(User? user) if (user is null) return false; - var ownedOrganizations = await GetOwnedOrganizationsAsync(user.Id, user.OrganizationIds); - return !ownedOrganizations.Any(o => _plans.IsFreeOrTrialPlan(o.PlanId)); + return (await GetOwnedFreeOrTrialOrganizationsAsync(user.Id, user.OrganizationIds)).Count == 0; } /// @@ -59,7 +58,7 @@ public async Task CanStartTrialAsync(User user) if (_billingOptions.TrialDays <= 0 || user.TrialStartedUtc is not null) return false; - return (await GetOwnedOrganizationsAsync(user.Id, user.OrganizationIds)).Count == 0; + return !await OwnsAnyOrganizationAsync(user.Id, user.OrganizationIds); } public void StartTrial(Organization organization, User user) @@ -167,26 +166,46 @@ private async Task OwnerHasOtherFreeOrTrialOrganizationAsync(Organization ? actingUser.OrganizationIds : (await _userRepository.GetByIdAsync(ownerId))?.OrganizationIds ?? new HashSet(); - var ownedOrganizations = await GetOwnedOrganizationsAsync(ownerId, ownerOrganizationIds); - return ownedOrganizations.Any(o => !String.Equals(o.Id, organization.Id, StringComparison.Ordinal) && _plans.IsFreeOrTrialPlan(o.PlanId)); + var ownedOrganizations = await GetOwnedFreeOrTrialOrganizationsAsync(ownerId, ownerOrganizationIds); + return ownedOrganizations.Any(o => !String.Equals(o.Id, organization.Id, StringComparison.Ordinal)); + } + + // Ownership is the organization's creator, including organizations the creator is no longer a member of. + // Organizations created before CreatedByUserId existed fall back to the member who last changed the billing plan. + + private async Task OwnsAnyOrganizationAsync(string userId, ICollection membershipOrganizationIds) + { + var created = await _organizationRepository.CountAsync(q => q.FieldEquals(o => o.CreatedByUserId, userId)); + if (created.Total > 0) + return true; + + return (await GetLegacyOwnedOrganizationsAsync(userId, membershipOrganizationIds)).Count > 0; } /// - /// Organizations the user created, including ones they're no longer a member of. Organizations created before - /// existed fall back to the member who last changed the billing plan. + /// The user's free and trial organizations. The one-free-organization rule keeps this to a handful, so a single + /// page is enough regardless of how many paid organizations the user owns. /// - private async Task> GetOwnedOrganizationsAsync(string userId, ICollection membershipOrganizationIds) + private async Task> GetOwnedFreeOrTrialOrganizationsAsync(string userId, ICollection membershipOrganizationIds) { - var createdOrganizations = await _organizationRepository.FindAsync(q => q.FieldEquals(o => o.CreatedByUserId, userId), o => o.PageLimit(100)); - var ownedOrganizations = createdOrganizations.Documents.ToList(); + var created = await _organizationRepository.FindAsync(q => q + .FieldEquals(o => o.CreatedByUserId, userId) + .FieldEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id), o => o.PageLimit(100)); + var legacy = await GetLegacyOwnedOrganizationsAsync(userId, membershipOrganizationIds); + + return created.Documents + .Concat(legacy.Where(o => _plans.IsFreeOrTrialPlan(o.PlanId))) + .DistinctBy(o => o.Id) + .ToList(); + } - if (membershipOrganizationIds.Count > 0) - { - var memberOrganizations = await _organizationRepository.GetByIdsAsync(membershipOrganizationIds.ToArray()); - ownedOrganizations.AddRange(memberOrganizations.Where(o => o.CreatedByUserId is null && String.Equals(o.BillingChangedByUserId, userId, StringComparison.Ordinal))); - } + private async Task> GetLegacyOwnedOrganizationsAsync(string userId, ICollection membershipOrganizationIds) + { + if (membershipOrganizationIds.Count == 0) + return []; - return ownedOrganizations.DistinctBy(o => o.Id).ToList(); + var memberOrganizations = await _organizationRepository.GetByIdsAsync(membershipOrganizationIds.ToArray()); + return memberOrganizations.Where(o => o.CreatedByUserId is null && String.Equals(o.BillingChangedByUserId, userId, StringComparison.Ordinal)).ToList(); } private static string GetOrganizationLockKey(string organizationId) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 6a26b57a8c..d57278f6a9 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -2879,6 +2879,33 @@ await WithBillingEnabledAsync(() => SendRequestAsync(r => r )); } + [Fact] + public async Task PostAsync_OwnerWithManyPaidOrganizationsAndOneFree_CannotCreateAnotherFreeOrganization() + { + // Arrange + const string password = "Owner password"; + var owner = await CreateUserAsync("many-organizations-owner@exceptionless.test", password, isEmailAddressVerified: true); + var organizations = Enumerable.Range(0, 101).Select(index => + { + var paidOrganization = new Organization { Name = $"Paid Organization {index}", CreatedByUserId = owner.Id, StripeCustomerId = $"cus_{index}", CardLast4 = "4242", SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime }; + _billingManager.ApplyBillingPlan(paidOrganization, _plans.SmallPlan, owner); + return paidOrganization; + }).ToList(); + var freeOrganization = new Organization { Name = "Only Free Organization", CreatedByUserId = owner.Id }; + _billingManager.ApplyBillingPlan(freeOrganization, _plans.FreePlan, owner); + organizations.Add(freeOrganization); + await _organizationRepository.AddAsync(organizations, o => o.ImmediateConsistency()); + + // Act & Assert + await WithBillingEnabledAsync(() => SendRequestAsync(r => r + .BasicAuthorization(owner.EmailAddress, password) + .Post() + .AppendPath("organizations") + .Content(new NewOrganization { Name = "Second Free Organization" }) + .StatusCodeShouldBeUpgradeRequired() + )); + } + [Fact] public async Task ChangePlanAsync_MemberDowngradesOwnersSecondOrganizationToFree_ReturnsFailure() { From beb2f7706db5ac3af71303ea6675584545f8dc0f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 09:35:11 -0500 Subject: [PATCH 08/35] Page plan stats and remove an organization whose creator save fails - GetBillingPlanStatsAsync read only the first page of organizations, so every counter (including the new trial ones) undercounted on larger installs; page through all organizations - If adding the creator's membership fails after the organization is saved, remove the organization so it doesn't count against the user's free-organization limit while being unreachable Co-Authored-By: Claude Opus 5.5 --- .../Repositories/OrganizationRepository.cs | 11 ++++++-- .../Api/Handlers/OrganizationHandler.cs | 12 ++++++++- .../OrganizationRepositoryTests.cs | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs index c5c99d8b69..1429d3ba64 100644 --- a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs +++ b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs @@ -114,11 +114,18 @@ public Task> GetByCriteriaAsync(string? criteria, Comm return FindAsync(q => query, options); } + internal const int BillingPlanStatsPageLimit = 250; + public async Task GetBillingPlanStatsAsync() { - var results = (await FindAsync(q => q + var page = await FindAsync(q => q .Include(o => o.PlanId, o => o.IsSuspended, o => o.BillingPrice, o => o.BillingStatus, o => o.TrialEndsUtc) - .SortDescending(o => o.PlanId))).Documents; + .SortAscending(o => o.Id), o => o.SearchAfterPaging().PageLimit(BillingPlanStatsPageLimit)); + var results = new List(); + do + { + results.AddRange(page.Documents); + } while (await page.NextPageAsync()); var smallOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.SmallPlan.Id) && o.BillingPrice > 0).ToList(); var mediumOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.MediumPlan.Id) && o.BillingPrice > 0).ToList(); var largeOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.LargePlan.Id) && o.BillingPrice > 0).ToList(); diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index b62195c450..4d9ea7035b 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -875,7 +875,17 @@ private async Task AddModelAsync(Organization value, User user) var organization = await repository.AddAsync(value, o => o.ImmediateConsistency().Cache()); user.OrganizationIds.Add(organization.Id); - await userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + try + { + await userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + } + catch (Exception ex) + { + // Without the membership the user couldn't reach the organization, but it would still count as theirs. + _logger.LogError(ex, "Unable to add user {UserId} to new organization {OrganizationId}, removing it: {Message}", user.Id, organization.Id, ex.Message); + await repository.RemoveAsync(organization, o => o.ImmediateConsistency()); + throw; + } await messagePublisher.PublishAsync(new UserMembershipChanged { UserId = user.Id, diff --git a/tests/Exceptionless.Tests/Repositories/OrganizationRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/OrganizationRepositoryTests.cs index 47fc328ccc..ec845cf33e 100644 --- a/tests/Exceptionless.Tests/Repositories/OrganizationRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/OrganizationRepositoryTests.cs @@ -242,4 +242,30 @@ public async Task GetByCriteria_SortByName_ReturnsSortedResults() var names = results.Documents.Select(d => d.Name).ToList(); Assert.Equal(names.OrderBy(n => n), names); } + + [Fact] + public async Task GetBillingPlanStatsAsync_MoreOrganizationsThanOnePage_CountsEveryOrganization() + { + // Arrange + int trialCount = OrganizationRepository.BillingPlanStatsPageLimit + 1; + var organizations = Enumerable.Range(0, trialCount).Select(index => new Organization + { + Name = $"Trial Organization {index}", + PlanId = _plans.TrialPlan.Id, + PlanName = _plans.TrialPlan.Name, + PlanDescription = _plans.TrialPlan.Description, + HasPremiumFeatures = true, + TrialEndsUtc = DateTime.UtcNow.AddDays(3) + }).ToList(); + organizations.Add(new Organization { Name = "Expired Trial", PlanId = _plans.FreePlan.Id, PlanName = "Free", PlanDescription = "Free", TrialEndsUtc = DateTime.UtcNow.AddDays(-1) }); + await _repository.AddAsync(organizations, o => o.ImmediateConsistency()); + + // Act + var stats = await _repository.GetBillingPlanStatsAsync(); + + // Assert + Assert.Equal(trialCount, stats.TrialAccounts); + Assert.Equal(1, stats.ExpiredTrialAccounts); + Assert.Equal(0, stats.PaidAccounts); + } } From 4610ad561559a1736f8d1839977c725195304048 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 09:45:36 -0500 Subject: [PATCH 09/35] Send trial reminders to every member before marking them sent - Page through all organization members (user limits are gone, so members can exceed one page) - Record TrialReminderSentUtc only after every reminder is queued, so a queueing failure is retried on the next run instead of silently skipping the reminder Co-Authored-By: Claude Opus 5.5 --- .../Jobs/TrialExpirationJob.cs | 13 +++++++----- .../Jobs/TrialExpirationJobTests.cs | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs index f77ef7d54b..f09cfe178b 100644 --- a/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs +++ b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs @@ -104,14 +104,17 @@ private async Task ProcessOrganizationAsync(string organizationId) if (organization.TrialReminderSentUtc is not null || organization.TrialEndsUtc.Value.AddDays(-_billingOptions.TrialReminderDays) > utcNow) return; - // Mark the reminder before sending so a failure can't cause repeated emails. + var users = await _userRepository.GetByOrganizationIdAsync(organization.Id, o => o.SearchAfterPaging().PageLimit(100)); + do + { + foreach (var user in users.Documents.Where(u => u.IsEmailAddressVerified && u.EmailNotificationsEnabled)) + await _mailer.SendOrganizationTrialEndingAsync(user, organization); + } while (await users.NextPageAsync()); + + // Mark the reminder only after every email is queued, so a failure is retried on the next run. organization.TrialReminderSentUtc = utcNow; await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); - var users = await _userRepository.GetByOrganizationIdAsync(organization.Id); - foreach (var user in users.Documents.Where(u => u.IsEmailAddressVerified && u.EmailNotificationsEnabled)) - await _mailer.SendOrganizationTrialEndingAsync(user, organization); - _logger.LogInformation("Sent trial ending reminder for organization {OrganizationId}", organization.Id); } diff --git a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs index 31254cd889..40b46d649c 100644 --- a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs @@ -94,6 +94,27 @@ public async Task RunAsync_TrialLifecycle_SendsOneReminderThenMovesToFreePlan() Assert.Equal(BillingPlans.MediumPlan.Id, paidOrganization.PlanId); } + [Fact] + public async Task RunAsync_TrialWithMoreMembersThanOnePage_RemindsEveryVerifiedMember() + { + // Arrange + var members = Enumerable.Range(0, 101).Select(index => + { + var member = UserData.GenerateUser(generateId: true, organizationId: TrialOrganizationId, emailAddress: $"trial-member-{index}@example.org"); + member.MarkEmailAddressVerified(); + return member; + }).ToList(); + await UserRepository.AddAsync(members, options => options.ImmediateConsistency()); + TimeProvider.Advance(TimeSpan.FromDays(5.5)); + + // Act + await Job.RunAsync(TestCancellationToken); + + // Assert + Assert.Equal(members.Count + 1, Mailer.OrganizationTrialEndingCalls.Count); + Assert.All(members, member => Assert.Contains(Mailer.OrganizationTrialEndingCalls, call => call.UserId == member.Id)); + } + [Fact] public async Task RunAsync_TrialWithoutEndDate_MovesToFreePlan() { From 6664a4fd18426da7a9cc436f986888ac3835b676 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 09:54:46 -0500 Subject: [PATCH 10/35] Serialize downgrades to Free per owner Downgrades to Free now take the same per-owner lock as organization creation, so two concurrent downgrades of one owner's paid organizations can't both pass the one-free-organization rule. Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 15 +++++++-- .../Api/Handlers/OrganizationHandler.cs | 7 +++- .../Endpoints/OrganizationEndpointTests.cs | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index f3ab3908e9..97d247a9fd 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -31,12 +31,21 @@ public BillingManager(ILockProvider lockProvider, IOrganizationRepository organi public Task AcquireOrganizationLockAsync(string organizationId) => _lockProvider.AcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.FromSeconds(30)); - public Task AcquireUserOrganizationCreationLockAsync(string userId) + /// + /// Serializes changes that can give an owner a free or trial organization (creating one, or downgrading to Free), + /// so concurrent requests can't both pass the one-free-organization rule. + /// + public Task AcquireOwnerFreeOrganizationLockAsync(string userId) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); - return _lockProvider.AcquireAsync($"User:{userId}:create-organization", TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30)); + return _lockProvider.AcquireAsync($"User:{userId}:free-organization", TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30)); } + /// + /// The organization's owner: its creator, or for organizations created before that was recorded, the user who last changed its plan. + /// + public static string? GetOwnerId(Organization organization) => organization.CreatedByUserId ?? organization.BillingChangedByUserId; + public Task TryAcquireOrganizationLockAsync(string organizationId) => _lockProvider.TryAcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.Zero); @@ -161,7 +170,7 @@ internal void SetStripeSubscriptionId(Organization organization, string? subscri private async Task OwnerHasOtherFreeOrTrialOrganizationAsync(Organization organization, User actingUser) { - string ownerId = organization.CreatedByUserId ?? organization.BillingChangedByUserId ?? actingUser.Id; + string ownerId = GetOwnerId(organization) ?? actingUser.Id; var ownerOrganizationIds = String.Equals(ownerId, actingUser.Id, StringComparison.Ordinal) ? actingUser.OrganizationIds : (await _userRepository.GetByIdAsync(ownerId))?.OrganizationIds ?? new HashSet(); diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 4d9ea7035b..32bed902ab 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -114,7 +114,7 @@ public async Task> Handle(CreateOrganization message) // Serialize creation per user and reload the user so concurrent requests can't both pass the // one-free-organization and one-trial checks. string userId = GetCurrentUser(message.Context).Id; - await using var userLock = await billingManager.AcquireUserOrganizationCreationLockAsync(userId); + await using var ownerLock = await billingManager.AcquireOwnerFreeOrganizationLockAsync(userId); var user = await userRepository.GetByIdAsync(userId, o => o.Cache(false)); if (user is null) return Result.Unauthorized("User not found."); @@ -396,6 +396,11 @@ public async Task> Handle(ChangeOrganizationPlan messag if (String.Equals(organization.PlanId, plan.Id) && String.Equals(plans.FreePlan.Id, plan.Id)) return ChangePlanResult.SuccessWithMessage("Your plan was not changed as you were already on the free plan."); + // Downgrades to Free share the owner's lock with organization creation so two can't both pass the one-free-organization rule. + await using var ownerLock = plans.IsFreePlan(plan.Id) && !String.Equals(organization.PlanId, plan.Id) + ? await billingManager.AcquireOwnerFreeOrganizationLockAsync(BillingManager.GetOwnerId(organization) ?? GetCurrentUser(message.Context).Id) + : null; + if (!String.Equals(organization.PlanId, plan.Id)) { var result = await billingManager.CanDownGradeAsync(organization, plan, GetCurrentUser(message.Context)); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index d57278f6a9..3a50993fcb 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -2906,6 +2906,39 @@ await WithBillingEnabledAsync(() => SendRequestAsync(r => r )); } + [Fact] + public async Task ChangePlanAsync_ConcurrentDowngradesOfTwoOwnedOrganizations_AllowsOnlyOneFree() + { + // Arrange + const string password = "Owner password"; + var owner = await CreateUserAsync("concurrent-downgrade-owner@exceptionless.test", password, isEmailAddressVerified: true); + var organizationIds = new List(); + for (int index = 0; index < 2; index++) + { + var paidOrganization = new Organization { Name = $"Concurrent Paid Organization {index}", CreatedByUserId = owner.Id, StripeCustomerId = $"cus_concurrent_{index}", CardLast4 = "4242", SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime }; + _billingManager.ApplyBillingPlan(paidOrganization, _plans.SmallPlan, owner); + organizationIds.Add((await AddMemberOrganizationAsync(owner, paidOrganization)).Id); + } + + // Act + var results = await WithBillingEnabledAsync(async () => + { + using var client = CreateHttpClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{owner.EmailAddress}:{password}"))); + var responses = await Task.WhenAll(organizationIds.Select(organizationId => client.PostAsync($"organizations/{organizationId}/change-plan", + System.Net.Http.Json.JsonContent.Create(new { plan_id = _plans.FreePlan.Id }), TestCancellationToken))); + + return await Task.WhenAll(responses.Select(response => response.DeserializeAsync())); + }); + + // Assert + Assert.Single(results, result => result?.Success is true); + await RefreshDataAsync(); + var organizations = await _organizationRepository.GetByIdsAsync(organizationIds); + Assert.Single(organizations, organization => organization.PlanId == _plans.FreePlan.Id); + } + [Fact] public async Task ChangePlanAsync_MemberDowngradesOwnersSecondOrganizationToFree_ReturnsFailure() { From 4862be211b9d10a1aea3de757f90a75ab01c2caa Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 10:03:48 -0500 Subject: [PATCH 11/35] Backfill organization creators instead of deriving ownership Migration 11 now copies BillingChangedByUserId into CreatedByUserId for existing organizations, so ownership always uses the immutable creator. This removes the membership-based fallback, which read the mutable billing audit field and changed owner whenever a member changed the plan. Sample data records the creator too. Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 57 ++++--------------- .../011_UpdateOrganizationsForTrials.cs | 10 ++-- .../Utility/SampleDataService.cs | 6 +- .../Api/Handlers/OrganizationHandler.cs | 2 +- .../Endpoints/OrganizationEndpointTests.cs | 2 +- ...ateOrganizationsForTrialsMigrationTests.cs | 4 +- 6 files changed, 23 insertions(+), 58 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 97d247a9fd..e7367fb931 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -13,16 +13,14 @@ public class BillingManager private static readonly TimeSpan BillingLockDuration = TimeSpan.FromMinutes(5); private readonly ILockProvider _lockProvider; private readonly IOrganizationRepository _organizationRepository; - private readonly IUserRepository _userRepository; private readonly BillingPlans _plans; private readonly BillingOptions _billingOptions; private readonly TimeProvider _timeProvider; - public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, IUserRepository userRepository, BillingPlans plans, AppOptions appOptions, TimeProvider timeProvider) + public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, BillingPlans plans, AppOptions appOptions, TimeProvider timeProvider) { _lockProvider = lockProvider; _organizationRepository = organizationRepository; - _userRepository = userRepository; _plans = plans; _billingOptions = appOptions.BillingOptions; _timeProvider = timeProvider; @@ -41,11 +39,6 @@ public Task AcquireOwnerFreeOrganizationLockAsync(string userId) return _lockProvider.AcquireAsync($"User:{userId}:free-organization", TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30)); } - /// - /// The organization's owner: its creator, or for organizations created before that was recorded, the user who last changed its plan. - /// - public static string? GetOwnerId(Organization organization) => organization.CreatedByUserId ?? organization.BillingChangedByUserId; - public Task TryAcquireOrganizationLockAsync(string organizationId) => _lockProvider.TryAcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.Zero); @@ -54,7 +47,7 @@ public async Task CanAddOrganizationAsync(User? user) if (user is null) return false; - return (await GetOwnedFreeOrTrialOrganizationsAsync(user.Id, user.OrganizationIds)).Count == 0; + return (await GetOwnedFreeOrTrialOrganizationsAsync(user.Id)).Count == 0; } /// @@ -67,7 +60,8 @@ public async Task CanStartTrialAsync(User user) if (_billingOptions.TrialDays <= 0 || user.TrialStartedUtc is not null) return false; - return !await OwnsAnyOrganizationAsync(user.Id, user.OrganizationIds); + var owned = await _organizationRepository.CountAsync(q => q.FieldEquals(o => o.CreatedByUserId, user.Id)); + return owned.Total == 0; } public void StartTrial(Organization organization, User user) @@ -170,51 +164,20 @@ internal void SetStripeSubscriptionId(Organization organization, string? subscri private async Task OwnerHasOtherFreeOrTrialOrganizationAsync(Organization organization, User actingUser) { - string ownerId = GetOwnerId(organization) ?? actingUser.Id; - var ownerOrganizationIds = String.Equals(ownerId, actingUser.Id, StringComparison.Ordinal) - ? actingUser.OrganizationIds - : (await _userRepository.GetByIdAsync(ownerId))?.OrganizationIds ?? new HashSet(); - - var ownedOrganizations = await GetOwnedFreeOrTrialOrganizationsAsync(ownerId, ownerOrganizationIds); + var ownedOrganizations = await GetOwnedFreeOrTrialOrganizationsAsync(organization.CreatedByUserId ?? actingUser.Id); return ownedOrganizations.Any(o => !String.Equals(o.Id, organization.Id, StringComparison.Ordinal)); } - // Ownership is the organization's creator, including organizations the creator is no longer a member of. - // Organizations created before CreatedByUserId existed fall back to the member who last changed the billing plan. - - private async Task OwnsAnyOrganizationAsync(string userId, ICollection membershipOrganizationIds) - { - var created = await _organizationRepository.CountAsync(q => q.FieldEquals(o => o.CreatedByUserId, userId)); - if (created.Total > 0) - return true; - - return (await GetLegacyOwnedOrganizationsAsync(userId, membershipOrganizationIds)).Count > 0; - } - /// - /// The user's free and trial organizations. The one-free-organization rule keeps this to a handful, so a single - /// page is enough regardless of how many paid organizations the user owns. + /// The free and trial organizations a user created, including ones they're no longer a member of. The + /// one-free-organization rule keeps this to a handful, so a single page is enough. /// - private async Task> GetOwnedFreeOrTrialOrganizationsAsync(string userId, ICollection membershipOrganizationIds) + private async Task> GetOwnedFreeOrTrialOrganizationsAsync(string userId) { - var created = await _organizationRepository.FindAsync(q => q + var results = await _organizationRepository.FindAsync(q => q .FieldEquals(o => o.CreatedByUserId, userId) .FieldEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id), o => o.PageLimit(100)); - var legacy = await GetLegacyOwnedOrganizationsAsync(userId, membershipOrganizationIds); - - return created.Documents - .Concat(legacy.Where(o => _plans.IsFreeOrTrialPlan(o.PlanId))) - .DistinctBy(o => o.Id) - .ToList(); - } - - private async Task> GetLegacyOwnedOrganizationsAsync(string userId, ICollection membershipOrganizationIds) - { - if (membershipOrganizationIds.Count == 0) - return []; - - var memberOrganizations = await _organizationRepository.GetByIdsAsync(membershipOrganizationIds.ToArray()); - return memberOrganizations.Where(o => o.CreatedByUserId is null && String.Equals(o.BillingChangedByUserId, userId, StringComparison.Ordinal)).ToList(); + return results.Documents; } private static string GetOrganizationLockKey(string organizationId) diff --git a/src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs b/src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs index be02aa5239..0730aaa350 100644 --- a/src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs +++ b/src/Exceptionless.Core/Migrations/011_UpdateOrganizationsForTrials.cs @@ -10,8 +10,8 @@ namespace Exceptionless.Core.Migrations; /// /// Prepares organizations for free trials: maps so ownership can be -/// queried, and, because plans no longer limit users or projects, sets the limits stored on existing -/// organizations to unlimited. +/// queried and backfills it for existing organizations from the user who last changed the plan (the creator for +/// free organizations), and, because plans no longer limit users or projects, sets the stored limits to unlimited. /// public sealed class UpdateOrganizationsForTrials : MigrationBase { @@ -50,7 +50,8 @@ public override async Task RunAsync(MigrationContext context) context.CancellationToken.ThrowIfCancellationRequested(); var organizationsToMigrate = organizations.Documents - .Where(organization => organization.MaxUsers != -1 || organization.MaxProjects != -1) + .Where(organization => organization.MaxUsers != -1 || organization.MaxProjects != -1 + || (organization.CreatedByUserId is null && organization.BillingChangedByUserId is not null)) .ToList(); if (organizationsToMigrate.Count > 0) { @@ -58,6 +59,7 @@ public override async Task RunAsync(MigrationContext context) { organization.MaxUsers = -1; organization.MaxProjects = -1; + organization.CreatedByUserId ??= organization.BillingChangedByUserId; } await _organizationRepository.SaveAsync(organizationsToMigrate, options => options.Cache().Notifications(false)); @@ -68,6 +70,6 @@ public override async Task RunAsync(MigrationContext context) break; } - _logger.LogInformation("Removed user and project limits from {OrganizationCount} organizations", migrated); + _logger.LogInformation("Updated {OrganizationCount} organizations for free trials", migrated); } } diff --git a/src/Exceptionless.Core/Utility/SampleDataService.cs b/src/Exceptionless.Core/Utility/SampleDataService.cs index 49b1d8feba..859540416c 100644 --- a/src/Exceptionless.Core/Utility/SampleDataService.cs +++ b/src/Exceptionless.Core/Utility/SampleDataService.cs @@ -121,7 +121,7 @@ public async Task CreateOrganizationAndProjectAsync(User user) if (await _tokenRepository.ExistsAsync(TEST_API_KEY)) return; - var organization = new Organization { Id = TEST_ORG_ID, Name = "Acme" }; + var organization = new Organization { Id = TEST_ORG_ID, Name = "Acme", CreatedByUserId = user.Id }; _billingManager.ApplyBillingPlan(organization, _billingPlans.UnlimitedPlan, user); organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency().Cache()); @@ -200,7 +200,7 @@ public async Task CreateFreeOrganizationAndProjectAsync() if (await _tokenRepository.ExistsAsync(FREE_API_KEY)) return; - var organization = new Organization { Id = FREE_ORG_ID, Name = "Free Plan Organization" }; + var organization = new Organization { Id = FREE_ORG_ID, Name = "Free Plan Organization", CreatedByUserId = user.Id }; _billingManager.ApplyBillingPlan(organization, _billingPlans.FreePlan, user); organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency().Cache()); @@ -249,7 +249,7 @@ public async Task CreateInternalOrganizationAndProjectAsync(string userId) return; } - var organization = new Organization { Name = "Exceptionless" }; + var organization = new Organization { Name = "Exceptionless", CreatedByUserId = user.Id }; _billingManager.ApplyBillingPlan(organization, _billingPlans.UnlimitedPlan, user); organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency().Cache()); diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 32bed902ab..e43b89b3ce 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -398,7 +398,7 @@ public async Task> Handle(ChangeOrganizationPlan messag // Downgrades to Free share the owner's lock with organization creation so two can't both pass the one-free-organization rule. await using var ownerLock = plans.IsFreePlan(plan.Id) && !String.Equals(organization.PlanId, plan.Id) - ? await billingManager.AcquireOwnerFreeOrganizationLockAsync(BillingManager.GetOwnerId(organization) ?? GetCurrentUser(message.Context).Id) + ? await billingManager.AcquireOwnerFreeOrganizationLockAsync(organization.CreatedByUserId ?? GetCurrentUser(message.Context).Id) : null; if (!String.Equals(organization.PlanId, plan.Id)) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 3a50993fcb..e0056353b4 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -3000,7 +3000,7 @@ public async Task PostAsync_OwnerOfPaidOrganization_GetsFreePlan() // Arrange const string password = "Trial password"; var user = await CreateUserAsync("paid-owner@exceptionless.test", password, isEmailAddressVerified: true); - var paidOrganization = new Organization { Name = "Paid Organization", StripeCustomerId = "cus_paid", CardLast4 = "4242", SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime }; + var paidOrganization = new Organization { Name = "Paid Organization", CreatedByUserId = user.Id, StripeCustomerId = "cus_paid", CardLast4 = "4242", SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime }; _billingManager.ApplyBillingPlan(paidOrganization, _plans.SmallPlan, user); await AddMemberOrganizationAsync(user, paidOrganization); diff --git a/tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs index a6549af8ed..09a4b2650c 100644 --- a/tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/UpdateOrganizationsForTrialsMigrationTests.cs @@ -27,10 +27,10 @@ protected override void RegisterServices(IServiceCollection services) } [Fact] - public async Task RunAsync_OrganizationsWithLegacyLimits_BecomeUnlimitedAndCreatorIsQueryable() + public async Task RunAsync_LegacyOrganizations_BecomeUnlimitedAndGetQueryableCreator() { // Arrange - var limited = new Organization { Name = "Legacy Free Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 1, MaxProjects = 1, CreatedByUserId = TestConstants.UserId }; + var limited = new Organization { Name = "Legacy Free Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 1, MaxProjects = 1, BillingChangedByUserId = TestConstants.UserId }; var deleted = new Organization { Name = "Deleted Small Organization", PlanId = "EX_FREE", PlanName = "Free", PlanDescription = "Free", MaxUsers = 10, MaxProjects = 5, IsDeleted = true }; await _organizationRepository.AddAsync([limited, deleted], options => options.ImmediateConsistency()); From 33ba4a116115b1e7043fe38862d966c0fd6e5bf5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 10:12:02 -0500 Subject: [PATCH 12/35] Keep the billing price when an admin applies a trial Admin plan changes preserve the billing price; applying EX_TRIAL reset it to zero, which a later admin change back to a paid plan didn't restore. Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 4 +-- .../Api/Handlers/AdminHandler.cs | 2 +- .../Api/Endpoints/AdminEndpointTests.cs | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index e7367fb931..79d7740249 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -76,12 +76,12 @@ public void StartTrial(Organization organization, User user) /// /// Applies the trial plan and starts a full-length trial. Also used by admins to extend a trial. /// - public void ApplyTrialPlan(Organization organization, User? user = null) + public void ApplyTrialPlan(Organization organization, User? user = null, bool updateBillingPrice = true) { ArgumentNullException.ThrowIfNull(organization); int trialDays = _billingOptions.TrialDays > 0 ? _billingOptions.TrialDays : 7; - ApplyBillingPlan(organization, _plans.TrialPlan, user); + ApplyBillingPlan(organization, _plans.TrialPlan, user, updateBillingPrice); organization.TrialEndsUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(trialDays); organization.TrialReminderSentUtc = null; } diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 59ce98df29..3a913b9c54 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -278,7 +278,7 @@ public async Task> Handle(AdminChangePlan message) organization.RemoveSuspension(); var currentUser = httpContext.Request.GetUser(); if (plans.IsTrialPlan(plan.Id)) - billingManager.ApplyTrialPlan(organization, currentUser); + billingManager.ApplyTrialPlan(organization, currentUser, updateBillingPrice: false); else billingManager.ApplyBillingPlan(organization, plan, currentUser, false); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs index b5fdb347b0..1ef63c255b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs @@ -1286,6 +1286,41 @@ public async Task ChangePlanAsync_ValidOrganizationAndPlan_ChangesOrganizationPl Assert.Equal(plans.SmallPlan.Id, organizationAfter.PlanId); } + [Fact] + public async Task ChangePlanAsync_TrialPlan_StartsTrialAndPreservesBillingPrice() + { + // Arrange + var plans = GetService(); + var organizationRepository = GetService(); + var organization = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.BillingPrice = 49; + organization.StripeCustomerId = "cus_admin_trial"; + organization.CardLast4 = "4242"; + organization.SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime; + await organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + + // Act + var result = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("admin", "change-plan") + .QueryString("organizationId", SampleDataService.TEST_ORG_ID) + .QueryString("planId", plans.TrialPlan.Id) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success); + + var organizationAfter = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organizationAfter); + Assert.Equal(plans.TrialPlan.Id, organizationAfter.PlanId); + Assert.Equal(49, organizationAfter.BillingPrice); + Assert.NotNull(organizationAfter.TrialEndsUtc); + Assert.True(organizationAfter.TrialEndsUtc > TimeProvider.GetUtcNow().UtcDateTime.AddDays(6)); + } + [Fact] public async Task ChangePlanAsync_InvalidPlanId_ReturnsFailure() { From deb3d55cd3c60c0b419179529fe633396b5e7fa9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 10:18:58 -0500 Subject: [PATCH 13/35] Don't fail Exie requests when the shared trial cost update fails The shared daily counter is updated after durable usage is recorded; if that cache update threw, the provider lifecycle treated the request as unreconciled and recorded the estimate again. Log and continue instead. Co-Authored-By: Claude Opus 5.5 --- .../Assistant/AssistantUsageService.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs index b37cb8697f..b9f1e15d60 100644 --- a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs @@ -325,16 +325,26 @@ await UpdateProviderUsageCacheWithInitializationAsync( /// Adjusts the shared daily cost counter for a reservation's day. The estimate is added when the provider /// request starts, then replaced by the actual cost or removed when the reservation is released. /// - private Task IncrementSharedDailyCostAsync(AssistantProviderReservation reservation, long costInMicrodollars) + private async Task IncrementSharedDailyCostAsync(AssistantProviderReservation reservation, long costInMicrodollars) { if (reservation.SharedDailyCostLimitKey is not { } sharedDailyCostLimitKey || costInMicrodollars == 0) - return Task.CompletedTask; + return; var usageDate = reservation.UsageDateUtc is { } date ? new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)) : timeProvider.GetUtcNow(); var day = GetDayWindow(usageDate); - return _cache.IncrementAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id), costInMicrodollars, day.ResetAtUtc.UtcDateTime); + + // The shared counter is a guardrail on top of each organization's own limits. It runs after durable usage is + // recorded, so a failure here must not fail the request and cause the usage to be recorded again. + try + { + await _cache.IncrementAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id), costInMicrodollars, day.ResetAtUtc.UtcDateTime); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to update the shared daily assistant cost for {SharedDailyCostLimitKey}: {Message}", sharedDailyCostLimitKey, ex.Message); + } } private async Task GetSharedDailyCostDecisionAsync(AssistantPlanOptions planOptions, DateTimeOffset now) From b2d80513e89fd143bddd2e14d4dc88cb005c53e9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 10:29:45 -0500 Subject: [PATCH 14/35] Only let admins put free or trial organizations on a trial Moving a paying organization onto the no-card trial left its Stripe subscription running and then dropped it to Free when the trial ended, losing its price. The admin trial action is for starting or extending a trial, so restrict it to free and trial organizations. Co-Authored-By: Claude Opus 5.5 --- .../Billing/BillingManager.cs | 4 +- .../Api/Handlers/AdminHandler.cs | 6 ++- .../Api/Endpoints/AdminEndpointTests.cs | 42 ++++++++++++++----- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 79d7740249..e7367fb931 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -76,12 +76,12 @@ public void StartTrial(Organization organization, User user) /// /// Applies the trial plan and starts a full-length trial. Also used by admins to extend a trial. /// - public void ApplyTrialPlan(Organization organization, User? user = null, bool updateBillingPrice = true) + public void ApplyTrialPlan(Organization organization, User? user = null) { ArgumentNullException.ThrowIfNull(organization); int trialDays = _billingOptions.TrialDays > 0 ? _billingOptions.TrialDays : 7; - ApplyBillingPlan(organization, _plans.TrialPlan, user, updateBillingPrice); + ApplyBillingPlan(organization, _plans.TrialPlan, user); organization.TrialEndsUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(trialDays); organization.TrialReminderSentUtc = null; } diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 3a913b9c54..7ddbfda594 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -274,11 +274,15 @@ public async Task> Handle(AdminChangePlan message) if (plan is null) return new ChangePlanResponse(false, "Invalid PlanId."); + // Trials start or extend a no-card trial. A paying organization keeps its subscription, so don't move it onto one. + if (plans.IsTrialPlan(plan.Id) && !plans.IsFreeOrTrialPlan(organization.PlanId)) + return new ChangePlanResponse(false, "Only free or trial organizations can be put on a trial."); + organization.BillingStatus = !plans.IsFreeOrTrialPlan(plan.Id) ? BillingStatus.Active : BillingStatus.Trialing; organization.RemoveSuspension(); var currentUser = httpContext.Request.GetUser(); if (plans.IsTrialPlan(plan.Id)) - billingManager.ApplyTrialPlan(organization, currentUser, updateBillingPrice: false); + billingManager.ApplyTrialPlan(organization, currentUser); else billingManager.ApplyBillingPlan(organization, plan, currentUser, false); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs index 1ef63c255b..2f0e3f3cc6 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/AdminEndpointTests.cs @@ -1287,18 +1287,41 @@ public async Task ChangePlanAsync_ValidOrganizationAndPlan_ChangesOrganizationPl } [Fact] - public async Task ChangePlanAsync_TrialPlan_StartsTrialAndPreservesBillingPrice() + public async Task ChangePlanAsync_TrialPlanOnFreeOrganization_StartsTrial() + { + // Arrange + var plans = GetService(); + var organizationRepository = GetService(); + + // Act + var result = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("admin", "change-plan") + .QueryString("organizationId", SampleDataService.FREE_ORG_ID) + .QueryString("planId", plans.TrialPlan.Id) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(result); + Assert.True(result.Success, result.Message); + + var organization = await organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(organization); + Assert.Equal(plans.TrialPlan.Id, organization.PlanId); + Assert.NotNull(organization.TrialEndsUtc); + Assert.True(organization.TrialEndsUtc > TimeProvider.GetUtcNow().UtcDateTime.AddDays(6)); + } + + [Fact] + public async Task ChangePlanAsync_TrialPlanOnPaidOrganization_ReturnsFailure() { // Arrange var plans = GetService(); var organizationRepository = GetService(); var organization = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); Assert.NotNull(organization); - organization.BillingPrice = 49; - organization.StripeCustomerId = "cus_admin_trial"; - organization.CardLast4 = "4242"; - organization.SubscribeDate = TimeProvider.GetUtcNow().UtcDateTime; - await organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + string originalPlanId = organization.PlanId; // Act var result = await SendRequestAsAsync(r => r @@ -1311,14 +1334,11 @@ public async Task ChangePlanAsync_TrialPlan_StartsTrialAndPreservesBillingPrice( // Assert Assert.NotNull(result); - Assert.True(result.Success); + Assert.False(result.Success); var organizationAfter = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); Assert.NotNull(organizationAfter); - Assert.Equal(plans.TrialPlan.Id, organizationAfter.PlanId); - Assert.Equal(49, organizationAfter.BillingPrice); - Assert.NotNull(organizationAfter.TrialEndsUtc); - Assert.True(organizationAfter.TrialEndsUtc > TimeProvider.GetUtcNow().UtcDateTime.AddDays(6)); + Assert.Equal(originalPlanId, organizationAfter.PlanId); } [Fact] From c9e65b1a340d4405aab01d0681f5167bcd870b2f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 10:40:51 -0500 Subject: [PATCH 15/35] Take the billing lock for admin plan changes Admin plan changes now share the organization billing lock with user plan changes and the trial expiration job, and reload the organization inside it, so an admin extending a trial can't race the job ending it. Co-Authored-By: Claude Opus 5.5 --- src/Exceptionless.Web/Api/Handlers/AdminHandler.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index 7ddbfda594..2680469c2b 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -266,7 +266,9 @@ public async Task> Handle(AdminChangePlan message) if (String.IsNullOrEmpty(message.OrganizationId) || !httpContext.Request.CanAccessOrganization(message.OrganizationId)) return new ChangePlanResponse(false, "Invalid Organization Id."); - var organization = await organizationRepository.GetByIdAsync(message.OrganizationId); + // Share the billing lock with user plan changes and the trial expiration job, and reload inside it. + await using var billingLock = await billingManager.AcquireOrganizationLockAsync(message.OrganizationId); + var organization = await organizationRepository.GetByIdAsync(message.OrganizationId, o => o.Cache(false)); if (organization is null) return new ChangePlanResponse(false, "Invalid Organization Id."); @@ -286,7 +288,7 @@ public async Task> Handle(AdminChangePlan message) else billingManager.ApplyBillingPlan(organization, plan, currentUser, false); - await organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); + await organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); await messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id From a77766f573552c1ecea35eccff1ac6ac68fb178a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 23:43:13 -0500 Subject: [PATCH 16/35] Make the free trial 14 days Co-Authored-By: Claude Opus 5.5 --- src/Exceptionless.Core/Billing/BillingManager.cs | 2 +- src/Exceptionless.Core/Configuration/BillingOptions.cs | 4 ++-- .../Api/Endpoints/OrganizationEndpointTests.cs | 2 +- tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs | 8 ++++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index e7367fb931..43b7899f71 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -80,7 +80,7 @@ public void ApplyTrialPlan(Organization organization, User? user = null) { ArgumentNullException.ThrowIfNull(organization); - int trialDays = _billingOptions.TrialDays > 0 ? _billingOptions.TrialDays : 7; + int trialDays = _billingOptions.TrialDays > 0 ? _billingOptions.TrialDays : 14; ApplyBillingPlan(organization, _plans.TrialPlan, user); organization.TrialEndsUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(trialDays); organization.TrialReminderSentUtc = null; diff --git a/src/Exceptionless.Core/Configuration/BillingOptions.cs b/src/Exceptionless.Core/Configuration/BillingOptions.cs index ce00e2e6ce..4fd9c2fbc8 100644 --- a/src/Exceptionless.Core/Configuration/BillingOptions.cs +++ b/src/Exceptionless.Core/Configuration/BillingOptions.cs @@ -7,7 +7,7 @@ public class BillingOptions /// /// Length of the no-card trial a user's first organization receives. Zero disables trials. /// - public int TrialDays { get; internal set; } = 7; + public int TrialDays { get; internal set; } = 14; /// /// Days before a trial ends to send the trial ending reminder email. @@ -32,7 +32,7 @@ public class BillingOptions public static BillingOptions ReadFromConfiguration(IConfiguration config) { var options = new BillingOptions(); - options.TrialDays = Math.Max(0, config.GetValue("Billing:TrialDays", 7)); + options.TrialDays = Math.Max(0, config.GetValue("Billing:TrialDays", 14)); options.TrialReminderDays = Math.Max(0, config.GetValue("Billing:TrialReminderDays", 2)); options.TrialAssistantDailyCostLimitUsd = config.GetValue("Billing:TrialAssistantDailyCostLimitUsd", 25m); options.FreeInviteDailyLimit = Math.Max(0, config.GetValue("Billing:FreeInviteDailyLimit", 10)); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index e0056353b4..c2683b873b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -2770,7 +2770,7 @@ public async Task PostAsync_FirstOrganizationWithBillingEnabled_StartsTrialAndAl Assert.True(organizationView.HasPremiumFeatures); Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, organizationView.MaxEventsPerMonth); Assert.NotNull(organizationView.TrialEndsUtc); - Assert.InRange(organizationView.TrialEndsUtc.Value, utcNow.AddDays(7).AddMinutes(-1), utcNow.AddDays(7).AddMinutes(1)); + Assert.InRange(organizationView.TrialEndsUtc.Value, utcNow.AddDays(14).AddMinutes(-1), utcNow.AddDays(14).AddMinutes(1)); Assert.NotNull(secondOrganizationProblem); var updatedUser = await _userRepository.GetByIdAsync(user.Id); diff --git a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs index 40b46d649c..eb9ddb0eb0 100644 --- a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs @@ -1,4 +1,6 @@ +using Exceptionless.Core; using Exceptionless.Core.Billing; +using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs; using Exceptionless.Core.Mail; @@ -30,6 +32,8 @@ public TrialExpirationJobTests(ITestOutputHelper output, AppWebHostFactory facto private BillingPlans BillingPlans => GetService(); private OrganizationData OrganizationData => GetService(); private UserData UserData => GetService(); + private BillingOptions BillingOptions => GetService().BillingOptions; + private TimeSpan TimeUntilReminder => TimeSpan.FromDays(BillingOptions.TrialDays - BillingOptions.TrialReminderDays + 0.5); protected override void RegisterServices(IServiceCollection services) { @@ -59,7 +63,7 @@ protected override async Task ResetDataAsync() public async Task RunAsync_TrialLifecycle_SendsOneReminderThenMovesToFreePlan() { // Arrange - TimeProvider.Advance(TimeSpan.FromDays(5.5)); + TimeProvider.Advance(TimeUntilReminder); // Act await Job.RunAsync(TestCancellationToken); @@ -105,7 +109,7 @@ public async Task RunAsync_TrialWithMoreMembersThanOnePage_RemindsEveryVerifiedM return member; }).ToList(); await UserRepository.AddAsync(members, options => options.ImmediateConsistency()); - TimeProvider.Advance(TimeSpan.FromDays(5.5)); + TimeProvider.Advance(TimeUntilReminder); // Act await Job.RunAsync(TestCancellationToken); From 6f684eed11934cd9308093331495d1c09aaf0681 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 22 Oct 2026 23:54:11 -0500 Subject: [PATCH 17/35] Replace the permanent trial and Free banners with timely notices Show the trial banner only in the last 3 days, dismissible until the next day, with a quiet days-left indicator in the sidebar. Replace the permanent Free banner with a one-time trial-ended notice recorded per user and organization. Add a dismissible warning at 80% of the monthly event limit, and send the trial reminder email 3 days before the trial ends. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/BillingOptions.cs | 4 +- .../src/lib/features/billing/trial.test.ts | 25 ++- .../src/lib/features/billing/trial.ts | 17 ++ .../components/notifications/index.ts | 8 +- .../notification-dismiss-button.svelte | 23 +++ .../organization-notifications.stories.svelte | 23 +-- ...svelte => trial-ended-notification.svelte} | 23 +-- .../notifications/trial-notification.svelte | 13 +- .../usage-warning-notification.svelte | 35 ++++ .../organization-notifications.svelte | 69 ++++++- .../organization-notifications.svelte.test.ts | 168 +++++++++++++++++- .../notification-dismissals.svelte.test.ts | 44 +++++ .../organizations/notification-dismissals.ts | 51 ++++++ .../lib/features/organizations/utils.test.ts | 34 +++- .../src/lib/features/organizations/utils.ts | 27 +++ .../src/lib/features/users/api.svelte.ts | 3 +- .../sidebar-organization-switcher.svelte | 5 + .../layouts/sidebar-trial-indicator.svelte | 34 ++++ .../Billing/BillingManagerTests.cs | 35 ++++ .../Jobs/TrialExpirationJobTests.cs | 2 +- 20 files changed, 597 insertions(+), 46 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/notification-dismiss-button.svelte rename src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/{free-plan-notification.svelte => trial-ended-notification.svelte} (62%) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/usage-warning-notification.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-trial-indicator.svelte diff --git a/src/Exceptionless.Core/Configuration/BillingOptions.cs b/src/Exceptionless.Core/Configuration/BillingOptions.cs index 4fd9c2fbc8..f2b0f672e4 100644 --- a/src/Exceptionless.Core/Configuration/BillingOptions.cs +++ b/src/Exceptionless.Core/Configuration/BillingOptions.cs @@ -12,7 +12,7 @@ public class BillingOptions /// /// Days before a trial ends to send the trial ending reminder email. /// - public int TrialReminderDays { get; internal set; } = 2; + public int TrialReminderDays { get; internal set; } = 3; /// /// Combined daily Exie cost limit across all trial organizations. Zero or less disables the limit. @@ -33,7 +33,7 @@ public static BillingOptions ReadFromConfiguration(IConfiguration config) { var options = new BillingOptions(); options.TrialDays = Math.Max(0, config.GetValue("Billing:TrialDays", 14)); - options.TrialReminderDays = Math.Max(0, config.GetValue("Billing:TrialReminderDays", 2)); + options.TrialReminderDays = Math.Max(0, config.GetValue("Billing:TrialReminderDays", 3)); options.TrialAssistantDailyCostLimitUsd = config.GetValue("Billing:TrialAssistantDailyCostLimitUsd", 25m); options.FreeInviteDailyLimit = Math.Max(0, config.GetValue("Billing:FreeInviteDailyLimit", 10)); options.PaidInviteDailyLimit = Math.Max(0, config.GetValue("Billing:PaidInviteDailyLimit", 50)); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts index 7dff7acf3d..fb24c34218 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { getTrialDaysRemaining, hasTrialEndedWithoutSubscribing } from './trial.ts'; +import { getTrialDaysRemaining, getTrialEndedNoticeKey, hasTrialEndedWithoutSubscribing, isTrialEndingSoon } from './trial.ts'; describe('getTrialDaysRemaining', () => { const now = new Date('2026-10-22T12:00:00Z'); it.each([ - ['a full trial', '2026-10-29T12:00:00Z', 7], + ['a full trial', '2026-11-05T12:00:00Z', 14], ['part of a day', '2026-10-22T18:00:00Z', 1], ['an ended trial', '2026-10-21T12:00:00Z', 0], ['no trial', null, 0], @@ -26,3 +26,24 @@ describe('hasTrialEndedWithoutSubscribing', () => { expect(hasTrialEndedWithoutSubscribing(organization)).toBe(expected); }); }); + +describe('isTrialEndingSoon', () => { + it.each([ + [14, false], + [4, false], + [3, true], + [1, true], + [0, true] + ])('returns whether %i days left is the end of the trial', (daysRemaining, expected) => { + expect(isTrialEndingSoon(daysRemaining)).toBe(expected); + }); +}); + +describe('getTrialEndedNoticeKey', () => { + it('returns a product tour record name the API accepts', () => { + const key = getTrialEndedNoticeKey('5f8e1b2c3d4e5f6a7b8c9d0e'); + + expect(key).toBe('trial-ended-5f8e1b2c3d4e5f6a7b8c9d0e'); + expect(key).toMatch(/^[a-z0-9-]{1,64}$/); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts index 8b30208a2c..ef35e95352 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/trial.ts @@ -1,5 +1,11 @@ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +/** The trial banner only shows in the last few days of a trial; before that the sidebar shows a quiet indicator. */ +export const TRIAL_ENDING_SOON_DAYS = 3; + +/** Product tour record name that marks the one-time trial-ended notice as dismissed for an organization. */ +export type TrialEndedNoticeKey = `trial-ended-${string}`; + /** * Whole days left in a trial, rounded up so a trial ending later today still shows one day left. * Returns 0 once the trial has ended. @@ -17,6 +23,10 @@ export function getTrialDaysRemaining(trialEndsUtc: null | string | undefined, n return Math.max(0, Math.ceil((endsAt - now.getTime()) / MILLISECONDS_PER_DAY)); } +export function getTrialEndedNoticeKey(organizationId: string): TrialEndedNoticeKey { + return `trial-ended-${organizationId}`; +} + /** * Whether a Free organization got there because its trial ended (or was ended early) without ever subscribing. * Organizations that converted to a paid plan and later downgraded keep their trial date, but didn't just leave a trial. @@ -28,3 +38,10 @@ export function hasTrialEndedWithoutSubscribing(organization: { subscribe_date?: return !organization.subscribe_date || organization.subscribe_date.startsWith('0001-01-01'); } + +/** + * Whether the trial is in its last few days, or has ended but the organization hasn't moved to the Free plan yet. + */ +export function isTrialEndingSoon(daysRemaining: number): boolean { + return daysRemaining <= TRIAL_ENDING_SOON_DAYS; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts index 1e6dee788c..86a4487aab 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts @@ -1,4 +1,3 @@ -import FreePlanNotification from './free-plan-notification.svelte'; import HourlyOverageNotification from './hourly-overage-notification.svelte'; import MonthlyOverageNotification from './monthly-overage-notification.svelte'; import PremiumUpgradeNotification from './premium-upgrade-notification.svelte'; @@ -6,12 +5,13 @@ import ProjectConfigurationNotification from './project-configuration-notificati import RequestLimitNotification from './request-limit-notification.svelte'; import SetupFirstProjectNotification from './setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './suspended-organization-notification.svelte'; +import TrialEndedNotification from './trial-ended-notification.svelte'; import TrialNotification from './trial-notification.svelte'; +import UsageWarningNotification from './usage-warning-notification.svelte'; export type { NotificationProps } from '#comp/notification/index.ts'; export { - FreePlanNotification, HourlyOverageNotification, MonthlyOverageNotification, PremiumUpgradeNotification, @@ -19,5 +19,7 @@ export { RequestLimitNotification, SetupFirstProjectNotification, SuspendedOrganizationNotification, - TrialNotification + TrialEndedNotification, + TrialNotification, + UsageWarningNotification }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/notification-dismiss-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/notification-dismiss-button.svelte new file mode 100644 index 0000000000..44c78780fd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/notification-dismiss-button.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte index e3d6504fb0..44c3457178 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte @@ -6,7 +6,6 @@ import { SuspensionCode } from '#features/organizations/models.ts'; - import FreePlanNotification from './free-plan-notification.svelte'; import HourlyOverageNotification from './hourly-overage-notification.svelte'; import ImpersonationNotification from './impersonation-notification.svelte'; import MonthlyOverageNotification from './monthly-overage-notification.svelte'; @@ -15,7 +14,9 @@ import RequestLimitNotification from './request-limit-notification.svelte'; import SetupFirstProjectNotification from './setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './suspended-organization-notification.svelte'; + import TrialEndedNotification from './trial-ended-notification.svelte'; import TrialNotification from './trial-notification.svelte'; + import UsageWarningNotification from './usage-warning-notification.svelte'; const mockProjects: ViewProject[] = [ { @@ -50,20 +51,20 @@ }); - - + + {}} organizationId="org1" /> - - + + {}} organizationId="org1" /> - - + + {}} organizationId="org1" /> - - + + {}} organizationId="org1" retentionDays={3} /> - - + + {}} organizationId="org1" total={2550} /> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-ended-notification.svelte similarity index 62% rename from src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte rename to src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-ended-notification.svelte index 3e036fbbb9..8a2e3c92b7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-ended-notification.svelte @@ -5,34 +5,35 @@ import { A } from '#comp/typography/index.ts'; import { showUpgradeDialog } from '#features/billing/upgrade-required.svelte.ts'; + import NotificationDismissButton from './notification-dismiss-button.svelte'; + interface Props extends NotificationProps { maxEventsPerMonth?: number; name: string; + onDismiss?: () => void; organizationId: string; retentionDays?: number; - trialEnded?: boolean; } - let { maxEventsPerMonth, name, organizationId, retentionDays, trialEnded = false, ...restProps }: Props = $props(); + let { maxEventsPerMonth, name, onDismiss, organizationId, retentionDays, ...restProps }: Props = $props(); - const hasLimits = $derived(typeof maxEventsPerMonth === 'number' && typeof retentionDays === 'number'); + const hasLimits = $derived(typeof maxEventsPerMonth === 'number' && maxEventsPerMonth > 0 && typeof retentionDays === 'number'); - - - {#if trialEnded} - Your trial has ended. {name} is now on the Free plan. - {:else} - {name} is currently on a free plan. + + {#snippet action()} + {#if onDismiss} + {/if} - + {/snippet} + Your trial has ended. {name} is now on the Free plan. {#if hasLimits} The Free plan includes {maxEventsPerMonth?.toLocaleString()} events per month and {retentionDays} days of data retention, without Exie, alerts, or integrations. {:else} The Free plan doesn't include Exie, alerts, or integrations. {/if} - showUpgradeDialog(organizationId, 'Upgrade to enable premium features and extra storage.')}>Upgrade now + showUpgradeDialog(organizationId, 'Upgrade to get Exie, alerts, integrations, and longer retention back.')}>Upgrade now to get them back. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte index 11a5ff1de2..c066dd2522 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/trial-notification.svelte @@ -5,19 +5,26 @@ import { A } from '#comp/typography/index.ts'; import { showUpgradeDialog } from '#features/billing/upgrade-required.svelte.ts'; + import NotificationDismissButton from './notification-dismiss-button.svelte'; + interface Props extends NotificationProps { daysRemaining: number; name: string; + onDismiss?: () => void; organizationId: string; } - let { daysRemaining, name, organizationId, ...restProps }: Props = $props(); + let { daysRemaining, name, onDismiss, organizationId, ...restProps }: Props = $props(); - const isEndingSoon = $derived(daysRemaining <= 2); const daysLabel = $derived(daysRemaining === 1 ? '1 day' : `${daysRemaining} days`); - + + {#snippet action()} + {#if onDismiss} + + {/if} + {/snippet} {#if daysRemaining > 0} {daysLabel} left in the {name} free trial. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/usage-warning-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/usage-warning-notification.svelte new file mode 100644 index 0000000000..13b13a806f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/usage-warning-notification.svelte @@ -0,0 +1,35 @@ + + + + {#snippet action()} + {#if onDismiss} + + {/if} + {/snippet} + {name} has used {percent}% of its monthly events. + + {total.toLocaleString()} of {limit.toLocaleString()} events used this month. Once it reaches the limit, new events are blocked until next month. + showUpgradeDialog(organizationId, 'Upgrade to raise your monthly event limit.')}>Upgrade now + to raise the limit. + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 9bb93e64a5..ed3b7826be 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -27,14 +27,16 @@ import type { NotificationProps } from '#comp/notification/index.ts'; import { FREE_PLAN_ID, TRIAL_PLAN_ID } from '#features/billing/constants.ts'; - import { getTrialDaysRemaining, hasTrialEndedWithoutSubscribing } from '#features/billing/trial.ts'; + import { getTrialDaysRemaining, getTrialEndedNoticeKey, hasTrialEndedWithoutSubscribing, isTrialEndingSoon } from '#features/billing/trial.ts'; import { getOrganizationQuery, getOrganizationsQuery } from '#features/organizations/api.svelte.ts'; import { organization as currentOrganizationId } from '#features/organizations/context.svelte.ts'; import { SuspensionCode } from '#features/organizations/models.ts'; + import { dismissOrganizationNotification, isOrganizationNotificationDismissed } from '#features/organizations/notification-dismissals.ts'; + import { getMonthlyEventUsage, isApproachingMonthlyEventLimit } from '#features/organizations/utils.ts'; + import { getProductTourRecordedAt } from '#features/product-tours/eligibility.ts'; import { getOrganizationProjectsQuery } from '#features/projects/api.svelte.ts'; - import { getMeQuery } from '#features/users/api.svelte.ts'; + import { getMeQuery, putCurrentUserProductTour } from '#features/users/api.svelte.ts'; - import FreePlanNotification from './notifications/free-plan-notification.svelte'; import HourlyOverageNotification from './notifications/hourly-overage-notification.svelte'; import ImpersonationNotification from './notifications/impersonation-notification.svelte'; import MonthlyOverageNotification from './notifications/monthly-overage-notification.svelte'; @@ -43,7 +45,9 @@ import RequestLimitNotification from './notifications/request-limit-notification.svelte'; import SetupFirstProjectNotification from './notifications/setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './notifications/suspended-organization-notification.svelte'; + import TrialEndedNotification from './notifications/trial-ended-notification.svelte'; import TrialNotification from './notifications/trial-notification.svelte'; + import UsageWarningNotification from './notifications/usage-warning-notification.svelte'; interface Props extends NotificationProps { ignoreConfigureProjects?: boolean; @@ -106,12 +110,46 @@ const isFreePlan = $derived(organization?.plan_id === FREE_PLAN_ID); const isTrialPlan = $derived(organization?.plan_id === TRIAL_PLAN_ID); const trialDaysRemaining = $derived(getTrialDaysRemaining(organization?.trial_ends_utc)); + const monthlyEventUsage = $derived(getMonthlyEventUsage(organization)); + + const recordProductTour = putCurrentUserProductTour(); + // Hides the trial-ended notice right away while the dismissal is saved to the user's product tour records. + const dismissedTrialEndedOrganizationIds = new SvelteSet(); + const trialEndedNoticeKey = $derived(organization?.id ? getTrialEndedNoticeKey(organization.id) : undefined); + const isTrialEndedNoticeDismissed = $derived( + !!organization?.id && + !!trialEndedNoticeKey && + (dismissedTrialEndedOrganizationIds.has(organization.id) || + !!getProductTourRecordedAt(meQuery.data?.product_tours, trialEndedNoticeKey.replaceAll('-', '_'))) + ); + + const showUsageWarning = $derived(isApproachingMonthlyEventLimit(organization) && !isOrganizationNotificationDismissed('usage-warning', organization?.id)); + const showTrialEndingSoon = $derived( + isTrialPlan && isTrialEndingSoon(trialDaysRemaining) && !isOrganizationNotificationDismissed('trial-ending', organization?.id) + ); + const showTrialEnded = $derived( + isFreePlan && !!organization && hasTrialEndedWithoutSubscribing(organization) && !!meQuery.data && !isTrialEndedNoticeDismissed + ); const hasNoProjects = $derived(projectsQuery.isSuccess && projects.length === 0); const needsProjectConfiguration = $derived( projectsQuery.isSuccess && !ignoreConfigureProjects && projects.length > 0 && projectsNeedingConfig.length === projects.length ); const requiresPremiumUpgrade = $derived(requiresPremium && !organization?.has_premium_features && !needsProjectConfiguration); + function dismissTrialEnded(): void { + const organizationId = organization?.id; + const userId = meQuery.data?.id; + if (!organizationId || !userId) { + return; + } + + dismissedTrialEndedOrganizationIds.add(organizationId); + recordProductTour.mutate({ + tourName: getTrialEndedNoticeKey(organizationId), + userId + }); + } + useEventListener(document, 'PersistentEventChanged', (event) => { const message = (event as CustomEvent>).detail; recordConfiguredProjectId(message, currentOrganizationId.current, configuredProjectIds); @@ -142,15 +180,30 @@ {:else if requiresPremiumUpgrade} - {:else if isTrialPlan} - - {:else if isFreePlan} - dismissOrganizationNotification('usage-warning', organization.id)} + organizationId={organization.id} + total={monthlyEventUsage.total} + {...restProps} + /> + {:else if showTrialEndingSoon} + dismissOrganizationNotification('trial-ending', organization.id)} + organizationId={organization.id} + {...restProps} + /> + {:else if showTrialEnded} + {:else if hasNoProjects} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte.test.ts index a1b888f53c..689fb2caf9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte.test.ts @@ -1,7 +1,10 @@ -import { render } from '@testing-library/svelte'; +import { fireEvent, render, screen } from '@testing-library/svelte'; import { tick } from 'svelte'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ViewOrganization } from '#features/organizations/models.ts'; + +import { dismissOrganizationNotification } from '#features/organizations/notification-dismissals.ts'; import { ChangeType } from '#features/websockets/models.ts'; import OrganizationNotifications, { recordConfiguredProjectId } from './organization-notifications.svelte'; @@ -9,9 +12,19 @@ import OrganizationNotifications, { recordConfiguredProjectId } from './organiza const organizationRefetch = vi.hoisted(() => vi.fn()); const projectsRefetch = vi.hoisted(() => vi.fn()); const projects = vi.hoisted(() => [{ id: 'project-id', is_configured: false, organization_id: 'organization-id' }]); +const recordProductTour = vi.hoisted(() => vi.fn()); +const state = vi.hoisted(() => ({ + me: { id: 'user-id', organization_ids: ['organization-id'], product_tours: {} as Record, roles: [] as string[] }, + organization: undefined as undefined | ViewOrganization +})); vi.mock('#features/organizations/api.svelte.ts', () => ({ - getOrganizationQuery: () => ({ data: undefined, refetch: organizationRefetch }), + getOrganizationQuery: () => ({ + get data() { + return state.organization; + }, + refetch: organizationRefetch + }), getOrganizationsQuery: () => ({ data: { data: [] } }) })); @@ -24,14 +37,68 @@ vi.mock('#features/projects/api.svelte.ts', () => ({ })); vi.mock('#features/users/api.svelte.ts', () => ({ - getMeQuery: () => ({ data: { organization_ids: ['organization-id'], roles: [] } }) + getMeQuery: () => ({ + get data() { + return state.me; + } + }), + putCurrentUserProductTour: () => ({ mutate: recordProductTour }) })); +const DAY = 24 * 60 * 60 * 1000; + +function createOrganization(overrides: Partial = {}): ViewOrganization { + return { + has_premium_features: true, + id: 'organization-id', + is_over_monthly_limit: false, + is_over_request_limit: false, + is_suspended: false, + is_throttled: false, + max_events_per_month: 3000, + name: 'Acme', + plan_id: 'EX_TRIAL', + retention_days: 3, + subscribe_date: null, + trial_ends_utc: new Date(Date.now() + 10 * DAY).toISOString(), + usage: [], + ...overrides + } as ViewOrganization; +} + +function createUsage(total: number, limit = 3000): ViewOrganization['usage'] { + const now = new Date(); + return [ + { + blocked: 0, + date: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(), + deleted: 0, + discarded: 0, + limit, + too_big: 0, + total + } + ]; +} + +function renderNotifications() { + return render(OrganizationNotifications, { + isChatEnabled: false, + openChat: vi.fn() + }); +} + describe('OrganizationNotifications', () => { beforeEach(() => { organizationRefetch.mockReset(); projectsRefetch.mockReset(); + recordProductTour.mockReset(); projects[0]!.is_configured = false; + state.me.product_tours = {}; + state.organization = undefined; + localStorage.clear(); + // Dismissing for a long-past day prunes every stored dismissal. + dismissOrganizationNotification('trial-ending', 'reset', new Date(2000, 0, 1)); }); it('records configuration events before project data loads', () => { @@ -94,4 +161,99 @@ describe('OrganizationNotifications', () => { expect(organizationRefetch).not.toHaveBeenCalled(); expect(projectsRefetch).not.toHaveBeenCalled(); }); + + describe('trial and usage notices', () => { + beforeEach(() => { + projects[0]!.is_configured = true; + }); + + it('does not show a trial banner before the last three days', () => { + state.organization = createOrganization(); + + renderNotifications(); + + expect(screen.queryByText(/free trial/)).toBeNull(); + }); + + it('shows a dismissible trial banner in the last three days until the next day', async () => { + state.organization = createOrganization({ trial_ends_utc: new Date(Date.now() + 2 * DAY).toISOString() }); + + const { unmount } = renderNotifications(); + expect(screen.getByText('2 days left in the Acme free trial.')).toBeTruthy(); + + await fireEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); + expect(screen.queryByText(/free trial/)).toBeNull(); + + unmount(); + renderNotifications(); + expect(screen.queryByText(/free trial/)).toBeNull(); + }); + + it('does not show a banner for a Free organization that never had a trial', () => { + state.organization = createOrganization({ plan_id: 'EX_FREE', trial_ends_utc: null }); + + renderNotifications(); + + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('shows the trial-ended notice with the organization limits and records the dismissal', async () => { + state.organization = createOrganization({ + has_premium_features: false, + plan_id: 'EX_FREE', + trial_ends_utc: new Date(Date.now() - DAY).toISOString() + }); + + renderNotifications(); + expect(screen.getByText('Your trial has ended. Acme is now on the Free plan.')).toBeTruthy(); + expect(screen.getByText(/3,000 events per month and 3 days of data retention/)).toBeTruthy(); + + await fireEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); + + expect(recordProductTour).toHaveBeenCalledWith({ tourName: 'trial-ended-organization-id', userId: 'user-id' }); + expect(screen.queryByText(/Your trial has ended/)).toBeNull(); + }); + + it('does not show the trial-ended notice once the user dismissed it on any device', () => { + state.me.product_tours = { trial_ended_organization_id: '2026-10-20T00:00:00Z' }; + state.organization = createOrganization({ plan_id: 'EX_FREE', trial_ends_utc: new Date(Date.now() - DAY).toISOString() }); + + renderNotifications(); + + expect(screen.queryByText(/Your trial has ended/)).toBeNull(); + }); + + it('shows a dismissible usage warning at 80% of the monthly limit', async () => { + state.organization = createOrganization({ plan_id: 'EX_SMALL', trial_ends_utc: null, usage: createUsage(2550) }); + + const { unmount } = renderNotifications(); + expect(screen.getByText('Acme has used 85% of its monthly events.')).toBeTruthy(); + + await fireEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); + expect(screen.queryByText(/monthly events/)).toBeNull(); + + unmount(); + renderNotifications(); + expect(screen.queryByText(/monthly events/)).toBeNull(); + }); + + it('shows the usage warning ahead of the trial banner', () => { + state.organization = createOrganization({ trial_ends_utc: new Date(Date.now() + DAY).toISOString(), usage: createUsage(2700) }); + + renderNotifications(); + + expect(screen.getByText('Acme has used 90% of its monthly events.')).toBeTruthy(); + expect(screen.queryByText(/free trial/)).toBeNull(); + }); + + it('shows the monthly overage banner instead of the usage warning at the limit', () => { + state.organization = createOrganization({ is_over_monthly_limit: true, plan_id: 'EX_SMALL', trial_ends_utc: null, usage: createUsage(3000) }); + + renderNotifications(); + + expect(screen.getByText('Acme has reached its monthly plan limit.')).toBeTruthy(); + expect(screen.queryByText(/of its monthly events/)).toBeNull(); + expect(screen.queryByRole('button', { name: 'Dismiss notification' })).toBeNull(); + }); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.svelte.test.ts new file mode 100644 index 0000000000..e37cd9f950 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.svelte.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { dismissOrganizationNotification, getDismissalPeriod, isOrganizationNotificationDismissed, pruneExpiredDismissals } from './notification-dismissals.ts'; + +describe('organization notification dismissals', () => { + beforeEach(() => { + localStorage.clear(); + // Dismissing for a long-past day prunes every other entry, leaving nothing that matches the dates below. + dismissOrganizationNotification('trial-ending', 'reset', new Date(2000, 0, 1)); + }); + + it('hides the trial-ending banner until the next local day', () => { + const evening = new Date(2026, 9, 22, 21, 0); + + dismissOrganizationNotification('trial-ending', 'organization-id', evening); + + expect(isOrganizationNotificationDismissed('trial-ending', 'organization-id', new Date(2026, 9, 22, 23, 59))).toBe(true); + expect(isOrganizationNotificationDismissed('trial-ending', 'organization-id', new Date(2026, 9, 23, 0, 1))).toBe(false); + expect(isOrganizationNotificationDismissed('trial-ending', 'other-organization-id', evening)).toBe(false); + }); + + it('hides the usage warning for the rest of the UTC month', () => { + dismissOrganizationNotification('usage-warning', 'organization-id', new Date('2026-10-02T12:00:00Z')); + + expect(isOrganizationNotificationDismissed('usage-warning', 'organization-id', new Date('2026-10-31T23:59:00Z'))).toBe(true); + expect(isOrganizationNotificationDismissed('usage-warning', 'organization-id', new Date('2026-11-01T00:01:00Z'))).toBe(false); + expect(isOrganizationNotificationDismissed('trial-ending', 'organization-id', new Date('2026-10-02T12:00:00Z'))).toBe(false); + }); + + it('drops dismissals from earlier periods', () => { + const now = new Date('2026-11-01T12:00:00Z'); + const current = { + 'trial-ending:old': '2026-10-01', + 'usage-warning:current': getDismissalPeriod('usage-warning', now), + 'usage-warning:old': '2026-10' + }; + + expect(pruneExpiredDismissals(current, now)).toEqual({ 'usage-warning:current': getDismissalPeriod('usage-warning', now) }); + }); + + it('ignores a missing organization', () => { + expect(isOrganizationNotificationDismissed('usage-warning', undefined)).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.ts new file mode 100644 index 0000000000..1b1b3be2b4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/notification-dismissals.ts @@ -0,0 +1,51 @@ +import { PersistedState } from 'runed'; + +/** + * Organization notifications a user can hide for a while. Each dismissal lasts for a period: + * the trial-ending banner until the next local day, the usage warning until the next UTC (billing) month. + */ +export type DismissibleOrganizationNotification = 'trial-ending' | 'usage-warning'; + +type Dismissals = Record; + +const dismissals = new PersistedState('organization-notification-dismissals', {}); + +export function dismissOrganizationNotification(notification: DismissibleOrganizationNotification, organizationId: string, now: Date = new Date()): void { + dismissals.current = { + ...pruneExpiredDismissals(dismissals.current, now), + [getDismissalKey(notification, organizationId)]: getDismissalPeriod(notification, now) + }; +} + +export function getDismissalPeriod(notification: DismissibleOrganizationNotification, now: Date = new Date()): string { + if (notification === 'usage-warning') { + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`; + } + + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} + +export function isOrganizationNotificationDismissed( + notification: DismissibleOrganizationNotification, + organizationId: string | undefined, + now: Date = new Date() +): boolean { + if (!organizationId) { + return false; + } + + return dismissals.current?.[getDismissalKey(notification, organizationId)] === getDismissalPeriod(notification, now); +} + +export function pruneExpiredDismissals(current: Dismissals | undefined, now: Date = new Date()): Dismissals { + return Object.fromEntries( + Object.entries(current ?? {}).filter(([key, period]) => { + const notification = key.slice(0, key.indexOf(':')) as DismissibleOrganizationNotification; + return period === getDismissalPeriod(notification, now); + }) + ); +} + +function getDismissalKey(notification: DismissibleOrganizationNotification, organizationId: string): string { + return `${notification}:${organizationId}`; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.test.ts index 92ccda8eec..8a7ed02c0f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { getUtcMonthKey } from './utils'; +import type { ViewOrganization } from './models'; + +import { getMonthlyEventUsage, getUtcMonthKey, isApproachingMonthlyEventLimit } from './utils'; describe('getUtcMonthKey', () => { it('changes only when the UTC month changes', () => { @@ -8,3 +10,33 @@ describe('getUtcMonthKey', () => { expect(getUtcMonthKey(new Date('2026-09-01T00:00:00.000Z'))).not.toBe(getUtcMonthKey(new Date('2026-08-31T23:59:59.999Z'))); }); }); + +describe('getMonthlyEventUsage', () => { + const now = new Date('2026-10-22T12:00:00Z'); + + function organizationWithUsage(usage: Partial[]): ViewOrganization { + return { + usage: usage.map((entry) => ({ blocked: 0, date: '2026-10-01T00:00:00Z', deleted: 0, discarded: 0, limit: 3000, too_big: 0, total: 0, ...entry })) + } as ViewOrganization; + } + + it('returns the current month usage against the limit', () => { + expect(getMonthlyEventUsage(organizationWithUsage([{ total: 2400 }]), now)).toEqual({ limit: 3000, percent: 0.8, total: 2400 }); + }); + + it('ignores usage from an earlier month', () => { + expect(getMonthlyEventUsage(organizationWithUsage([{ date: '2026-09-01T00:00:00Z', total: 2900 }]), now)).toBeUndefined(); + }); + + it('ignores unlimited plans', () => { + expect(getMonthlyEventUsage(organizationWithUsage([{ limit: -1, total: 2900 }]), now)).toBeUndefined(); + }); + + it.each([ + [2399, false], + [2400, true], + [3000, true] + ])('warns at 80%% of the limit (%i of 3000 events: %s)', (total, expected) => { + expect(isApproachingMonthlyEventLimit(organizationWithUsage([{ total }]), now)).toBe(expected); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.ts index 293208f74c..a739cab25e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/utils.ts @@ -5,6 +5,28 @@ import type { ViewOrganization } from './models'; export const ORGANIZATION_USAGE_REFETCH_INTERVAL_MS = 5 * 60 * 1000; export const ORGANIZATION_USAGE_ROLLOVER_CHECK_INTERVAL_MS = 60 * 1000; +/** Share of the monthly event limit at which the usage warning shows. */ +export const MONTHLY_USAGE_WARNING_THRESHOLD = 0.8; + +export interface MonthlyEventUsage { + limit: number; + percent: number; + total: number; +} + +/** + * Current UTC month event usage against the organization's limit, including bonus events. + * Returns undefined for unlimited plans or when there's no usage recorded for this month. + */ +export function getMonthlyEventUsage(organization?: ViewOrganization, now: Date = new Date()): MonthlyEventUsage | undefined { + const usage = organization?.usage?.[organization.usage.length - 1]; + if (!usage || usage.limit <= 0 || !isSameUtcMonth(new Date(usage.date), now)) { + return undefined; + } + + return { limit: usage.limit, percent: usage.total / usage.limit, total: usage.total }; +} + export function getNextBillingDateUtc(organization?: ViewOrganization): Date { if (organization?.subscribe_date) { console.log('Organization subscribe date for next billing date:', organization.subscribe_date); @@ -37,3 +59,8 @@ export function getRemainingEventLimit(organization?: ViewOrganization): number export function getUtcMonthKey(date = new Date()): number { return date.getUTCFullYear() * 12 + date.getUTCMonth(); } + +export function isApproachingMonthlyEventLimit(organization?: ViewOrganization, now: Date = new Date()): boolean { + const usage = getMonthlyEventUsage(organization, now); + return !!usage && usage.percent >= MONTHLY_USAGE_WARNING_THRESHOLD; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index e0fecf8f95..69f0cbb404 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -1,6 +1,7 @@ import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; +import type { TrialEndedNoticeKey } from '#features/billing/trial.ts'; import type { ProductTourKey } from '#features/product-tours/models.ts'; import type { WebSocketMessageValue } from '#features/websockets/models.ts'; import type { WorkInProgressResult } from '#shared/models/index.ts'; @@ -263,7 +264,7 @@ export function postEmailAddress(request: PostEmailAddressRequest) { export function putCurrentUserProductTour() { const queryClient = useQueryClient(); - return createMutation(() => ({ + return createMutation(() => ({ enabled: () => !!accessToken.current, mutationFn: async ({ tourName, userId }) => { if (queryClient.getQueryData(queryKeys.me())?.id !== userId) { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index a76dd86503..311056cdcc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -24,6 +24,8 @@ import GlobalUser from '#features/users/components/global-user.svelte'; import { getInitials } from '#shared/strings.ts'; + import SidebarTrialIndicator from './sidebar-trial-indicator.svelte'; + type Props = HTMLAttributes & { currentOrganizationId: string | undefined; impersonateDialogOpen?: boolean; @@ -241,6 +243,9 @@ {/if} + {#if !isImpersonating} + + {/if} {:else if isLoading} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-trial-indicator.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-trial-indicator.svelte new file mode 100644 index 0000000000..ba34773f27 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-trial-indicator.svelte @@ -0,0 +1,34 @@ + + +{#if organization?.id && daysRemaining > 0} + {@const organizationId = organization.id} + + showUpgradeDialog(organizationId, 'Upgrade to keep Exie, alerts, integrations, and 90-day retention when your trial ends.')} + > + + +{/if} diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index 3618c5200d..cfeb927991 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -1,5 +1,7 @@ using Exceptionless.Core.Billing; +using Exceptionless.Core.Configuration; using Exceptionless.Core.Models; +using Microsoft.Extensions.Configuration; using Xunit; namespace Exceptionless.Tests.Billing; @@ -97,4 +99,37 @@ public void SetStripeSubscriptionId_UnchangedOwner_PreservesEventWatermark() // Assert Assert.Equal(eventWatermarkUtc, organization.StripeSubscriptionEventDate); } + + [Fact] + public void ReadFromConfiguration_NoTrialSettings_UsesFourteenDayTrialWithThreeDayReminder() + { + // Arrange + var configuration = new ConfigurationBuilder().Build(); + + // Act + var options = BillingOptions.ReadFromConfiguration(configuration); + + // Assert + Assert.Equal(14, options.TrialDays); + Assert.Equal(3, options.TrialReminderDays); + } + + [Fact] + public void ApplyTrialPlan_DefaultOptions_EndsTrialInFourteenDays() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + var utcNow = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(utcNow); + var organization = new Organization { TrialReminderSentUtc = utcNow }; + + // Act + billingManager.ApplyTrialPlan(organization); + + // Assert + Assert.Equal(plans.TrialPlan.Id, organization.PlanId); + Assert.Equal(utcNow.AddDays(14), organization.TrialEndsUtc); + Assert.Null(organization.TrialReminderSentUtc); + } } diff --git a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs index eb9ddb0eb0..0b07f0be74 100644 --- a/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/TrialExpirationJobTests.cs @@ -79,7 +79,7 @@ public async Task RunAsync_TrialLifecycle_SendsOneReminderThenMovesToFreePlan() Assert.Equal(new OrganizationTrialEndingCall(VerifiedUserId, TrialOrganizationId), reminder); // Arrange - TimeProvider.Advance(TimeSpan.FromDays(2)); + TimeProvider.Advance(TimeSpan.FromDays(BillingOptions.TrialReminderDays)); // Act await Job.RunAsync(TestCancellationToken); From e640f5b9f96d2eb55e8119e6cd8228949f720913 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 23 Oct 2026 00:11:37 -0500 Subject: [PATCH 18/35] Keep notice dismissals reliable Save the trial-ended dismissal to the cached user right away and reload it if the save fails, skip the notice while impersonating so admins don't fill their own product tour records, and re-check day and month dismissals each minute. Co-Authored-By: Claude Opus 5.5 --- .../organization-notifications.svelte | 53 ++++++++----- .../organization-notifications.svelte.test.ts | 77 ++++++++++++++----- ...nization-notifications.test-fake.svelte.ts | 15 ++++ .../src/lib/features/users/api.svelte.ts | 18 +++++ 4 files changed, 124 insertions(+), 39 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.test-fake.svelte.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index ed3b7826be..23b475fd9d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -21,7 +21,8 @@ + +{#if settingsQuery.isPending} +
+ + Loading free trial settings... +
+{:else if settingsQuery.isError} +
+

Failed to load free trial settings.

+
+{:else if settings} + + +
+ Blocked trial email domains + {settings.is_default ? 'Built-in list' : 'Custom list'} +
+ + Signups from these domains and their subdomains get the Free plan instead of a free trial. Enter one domain per line. + +
+