From e5611758f1c55f477a572043644adb9f8d6c25f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 2 May 2026 10:31:28 +0200 Subject: [PATCH] feat: implement Stripe product configuration and add token-based input validation using Microsoft.ML.Tokenizers --- .../Configuration/AiSettings.cs | 7 ++- .../Configuration/StripeSettings.cs | 9 +++ .../DependencyInjection.cs | 1 + .../NexusReader.Infrastructure.csproj | 2 + .../Services/BillingService.cs | 60 ++++++++++++++++--- .../Services/KnowledgeService.cs | 18 +++++- src/NexusReader.Web.New/appsettings.json | 8 ++- 7 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 src/NexusReader.Infrastructure/Configuration/StripeSettings.cs 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