- 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>
127 lines
4.5 KiB
C#
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..];
|
|
}
|