diff --git a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs
index 5678338..57610b6 100644
--- a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs
+++ b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs
@@ -6,7 +6,12 @@ public class AiSettings
public string ApiKey { get; set; } = string.Empty;
public string Model { get; set; } = "gemini-1.5-flash";
- public int MaxInputLength { get; set; } = 15000;
+
+ ///
+ /// Maximum number of tokens allowed for input.
+ ///
+ public int MaxInputTokens { get; set; } = 15000;
+
public int MaxOutputTokens { get; set; } = 1000;
public int RetryAttempts { get; set; } = 3;
public double Temperature { get; set; } = 0.1;
diff --git a/src/NexusReader.Infrastructure/Configuration/StripeSettings.cs b/src/NexusReader.Infrastructure/Configuration/StripeSettings.cs
new file mode 100644
index 0000000..0e026aa
--- /dev/null
+++ b/src/NexusReader.Infrastructure/Configuration/StripeSettings.cs
@@ -0,0 +1,9 @@
+namespace NexusReader.Infrastructure.Configuration;
+
+public record StripeSettings
+{
+ public const string SectionName = "Stripe";
+ public string ProProductId { get; init; } = string.Empty;
+ public string BasicProductId { get; init; } = string.Empty;
+ public string FreeProductId { get; init; } = string.Empty;
+}
diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs
index 66894c7..f09878b 100644
--- a/src/NexusReader.Infrastructure/DependencyInjection.cs
+++ b/src/NexusReader.Infrastructure/DependencyInjection.cs
@@ -35,6 +35,7 @@ public static class DependencyInjection
}
services.Configure(configuration.GetSection(AiSettings.SectionName));
+ services.Configure(configuration.GetSection(StripeSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings();
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj
index 9ea0cb8..a17f491 100644
--- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj
+++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj
@@ -15,6 +15,8 @@
+
+
diff --git a/src/NexusReader.Infrastructure/Services/BillingService.cs b/src/NexusReader.Infrastructure/Services/BillingService.cs
index ca47f5c..1b04d9e 100644
--- a/src/NexusReader.Infrastructure/Services/BillingService.cs
+++ b/src/NexusReader.Infrastructure/Services/BillingService.cs
@@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Domain.Entities;
+using NexusReader.Infrastructure.Configuration;
using NexusReader.Infrastructure.Persistence;
namespace NexusReader.Infrastructure.Services;
@@ -10,44 +13,83 @@ public class BillingService : IBillingService
{
private readonly AppDbContext _dbContext;
private readonly UserManager _userManager;
+ private readonly StripeSettings _stripeSettings;
+ private readonly ILogger _logger;
- public BillingService(AppDbContext dbContext, UserManager userManager)
+ public BillingService(
+ AppDbContext dbContext,
+ UserManager userManager,
+ IOptions stripeSettings,
+ ILogger logger)
{
_dbContext = dbContext;
_userManager = userManager;
+ _stripeSettings = stripeSettings.Value;
+ _logger = logger;
}
public async Task HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
{
var user = await _userManager.FindByEmailAsync(customerEmail);
- if (user == null) return false;
+ if (user == null)
+ {
+ _logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
+ return false;
+ }
- // Map Stripe Product IDs to Nexus Plans
- // These IDs would typically come from configuration
- if (stripeProductId.Contains("pro"))
+ if (stripeProductId == _stripeSettings.ProProductId)
{
user.CurrentPlan = "Pro";
user.AITokenLimit = 50000;
}
- else if (stripeProductId.Contains("basic"))
+ else if (stripeProductId == _stripeSettings.BasicProductId)
{
user.CurrentPlan = "Basic";
user.AITokenLimit = 10000;
}
+ else if (stripeProductId == _stripeSettings.FreeProductId || string.IsNullOrEmpty(stripeProductId))
+ {
+ user.CurrentPlan = "Free";
+ user.AITokenLimit = 1000;
+ }
+ else
+ {
+ _logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
+ user.CurrentPlan = "Free";
+ user.AITokenLimit = 1000;
+ }
+
+ var result = await _userManager.UpdateAsync(user);
+ if (!result.Succeeded)
+ {
+ _logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
+ customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
+ return false;
+ }
- await _userManager.UpdateAsync(user);
return true;
}
public async Task HandleSubscriptionDeletedAsync(string customerEmail)
{
var user = await _userManager.FindByEmailAsync(customerEmail);
- if (user == null) return false;
+ if (user == null)
+ {
+ _logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
+ return false;
+ }
user.CurrentPlan = "Free";
user.AITokenLimit = 1000; // Reset to free limit
- await _userManager.UpdateAsync(user);
+ var result = await _userManager.UpdateAsync(user);
+ if (!result.Succeeded)
+ {
+ _logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
+ customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
+ return false;
+ }
+
return true;
}
}
diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs
index ad74f13..bf2fda9 100644
--- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs
+++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs
@@ -2,6 +2,7 @@ using System.Text.Json;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
+using Microsoft.ML.Tokenizers;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
using NexusReader.Domain.Entities;
@@ -20,6 +21,7 @@ public class KnowledgeService : IKnowledgeService
private readonly AppDbContext _dbContext;
private readonly ResiliencePipeline _retryPipeline;
private readonly AiSettings _settings;
+ private readonly Tokenizer _tokenizer;
private const string PromptVersion = "1.0";
public KnowledgeService(
@@ -32,6 +34,9 @@ public class KnowledgeService : IKnowledgeService
_dbContext = dbContext;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_settings = settings.Value;
+ // Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
+ // a very reliable estimation for token usage in Gemini-based workloads.
+ _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
}
public async Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
@@ -59,10 +64,11 @@ public class KnowledgeService : IKnowledgeService
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
var normalizedText = ContentHasher.Normalize(text);
- if (normalizedText.Length > _settings.MaxInputLength)
+
+ var tokenCount = EstimateTokenCount(normalizedText);
+ if (tokenCount > _settings.MaxInputTokens)
{
- normalizedText = normalizedText.Substring(0, _settings.MaxInputLength);
- Console.WriteLine($"[KnowledgeService] WARNING: Input text truncated to {_settings.MaxInputLength} chars.");
+ return Result.Fail($"Input exceeds maximum token limit. Estimated tokens: {tokenCount}, limit: {_settings.MaxInputTokens}.");
}
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
@@ -151,4 +157,10 @@ public class KnowledgeService : IKnowledgeService
return Result.Fail($"Failed to clear cache: {ex.Message}");
}
}
+
+ private int EstimateTokenCount(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return 0;
+ return _tokenizer.CountTokens(text);
+ }
}
diff --git a/src/NexusReader.Web.New/appsettings.json b/src/NexusReader.Web.New/appsettings.json
index 747b806..a914efc 100644
--- a/src/NexusReader.Web.New/appsettings.json
+++ b/src/NexusReader.Web.New/appsettings.json
@@ -1,7 +1,10 @@
{
"Stripe": {
"ApiKey": "sk_test_placeholder",
- "WebhookSecret": "whsec_placeholder"
+ "WebhookSecret": "whsec_placeholder",
+ "ProProductId": "prod_Pro123",
+ "BasicProductId": "prod_Basic456",
+ "FreeProductId": "prod_Free789"
},
"Logging": {
"LogLevel": {
@@ -24,8 +27,9 @@
"Google": {
"ApiKey": "PLACEHOLDER",
"Model": "gemini-2.5-flash-lite",
+ "MaxInputTokens": 15000,
"MaxOutputTokens": 8192
}
},
"ApiBaseUrl": "http://localhost:5000"
-}
+}
\ No newline at end of file