NALU/src/Nalu.Api/Services/CreditService.cs
Ricardo Carneiro e01787ee60 Add deploy infrastructure, missing validators, and new features
- Add Docker Swarm deploy stack, CI workflow (.gitea), entrypoint script
- Fix Dockerfile to build Nalu.Web (was pointing to old Nalu.Api path)
- Add validate_name.md and other missing validators to prod
- Add Stripe endpoints, HangfireDashboardAuth, InputGuard, NameLookupService
- Add SuspiciousRateLimiter, En/ pages, Legal/ pages, Seguranca docs
- Add Nalu.Jobs and Nalu.NameImporter projects (were untracked)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:31:12 -03:00

127 lines
4.5 KiB
C#

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<CreditConsumeResult> 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<UsageMonthly>.Filter.And(
Builders<UsageMonthly>.Filter.Eq(u => u.ApiKey, apiKey),
Builders<UsageMonthly>.Filter.Eq(u => u.YearMonth, yearMonth));
var update = Builders<UsageMonthly>.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<UsageMonthly>
{
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<int?>($"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..];
}