using System.Security.Claims; using System.Text.Json; using MongoDB.Driver; using Nalu.Web.Data; using Nalu.Web.Data.Models; namespace Nalu.Web.Services; public record CreditConsumeResult { public bool Success { get; init; } public int CreditsConsumed { get; init; } public int CreditsUsed { get; init; } public int CreditsLimit { get; init; } public DateTime ResetAt { get; init; } public object? ErrorPayload { get; init; } public int CreditsRemaining => Math.Max(0, CreditsLimit - CreditsUsed); } public class CreditService(MongoDbContext db, IConfiguration config) { public async Task TryConsumeAsync( ClaimsPrincipal user, string validatorId, CancellationToken ct = default) { var apiKey = user.FindFirst("api_key")?.Value ?? ""; var plan = user.FindFirst("plan")?.Value ?? "free"; var cost = CreditCosts.Get(validatorId); var limit = GetPlanLimit(plan); var now = DateTime.UtcNow; var yearMonth = now.ToString("yyyy-MM"); var resetAt = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc) .AddMonths(1); if (!db.IsConnected) { // No MongoDB — allow all requests (dev/test mode) return new CreditConsumeResult { Success = true, CreditsConsumed = cost, CreditsUsed = cost, CreditsLimit = limit, ResetAt = resetAt }; } // Read current usage var current = await db.UsageMonthly .Find(u => u.ApiKey == apiKey && u.YearMonth == yearMonth) .FirstOrDefaultAsync(ct); var currentTotal = current?.TotalCreditsUsed ?? 0; if (limit > 0 && currentTotal + cost > limit) { return new CreditConsumeResult { Success = false, CreditsConsumed = 0, CreditsUsed = currentTotal, CreditsLimit = limit, ResetAt = resetAt, ErrorPayload = Build429Body(plan, currentTotal, limit, resetAt) }; } // Atomic increment var filter = Builders.Filter.And( Builders.Filter.Eq(u => u.ApiKey, apiKey), Builders.Filter.Eq(u => u.YearMonth, yearMonth)); var update = Builders.Update .Inc(u => u.TotalCreditsUsed, cost) .Inc(u => u.TotalRequests, 1) .Inc($"credits_by_validator.{validatorId}", cost) .Inc($"requests_by_validator.{validatorId}", 1) .Set(u => u.UpdatedAt, now) .SetOnInsert(u => u.Plan, plan) .SetOnInsert(u => u.ApiKey, apiKey) .SetOnInsert(u => u.YearMonth, yearMonth); var opts = new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }; var after = await db.UsageMonthly.FindOneAndUpdateAsync(filter, update, opts, ct); return new CreditConsumeResult { Success = true, CreditsConsumed = cost, CreditsUsed = after.TotalCreditsUsed, CreditsLimit = limit, ResetAt = resetAt }; } public void ApplyHeaders(HttpContext ctx, CreditConsumeResult result) { ctx.Response.Headers["X-Credits-Used"] = result.CreditsConsumed.ToString(); ctx.Response.Headers["X-Credits-Remaining"] = result.CreditsRemaining.ToString(); ctx.Response.Headers["X-Credits-Limit"] = result.CreditsLimit.ToString(); ctx.Response.Headers["X-Credits-Reset"] = result.ResetAt.ToString("O"); } private int GetPlanLimit(string plan) { var v = config.GetValue($"Plans:{plan}:credits_per_month"); return v ?? 0; // 0 = unlimited } private static object Build429Body(string plan, int used, int limit, DateTime resetAt) => new { error = "credits_exhausted", message = $"Seus créditos do mês acabaram. Seu plano ({Capitalize(plan)}) permite {limit:N0} créditos/mês.", credits_used = used, credits_limit = limit, reset_at = resetAt.ToString("O"), upgrade_url = "https://naluai.dev/precos", hint = "Upgrade para Starter por apenas R$ 0,0019 por validação. Menos que uma gota de café." }; private static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; }