diff --git a/JobTrackerApi.Tests/AccountPlansTests.cs b/JobTrackerApi.Tests/AccountPlansTests.cs index 776e9bb..d70143f 100644 --- a/JobTrackerApi.Tests/AccountPlansTests.cs +++ b/JobTrackerApi.Tests/AccountPlansTests.cs @@ -14,6 +14,7 @@ public sealed class AccountPlansTests Assert.False(free.AdvancedAi); Assert.True(premium.AdvancedAi); Assert.True(premium.MonthlyAiCalls > free.MonthlyAiCalls); + Assert.True(premium.MonthlyAiTokens > free.MonthlyAiTokens); Assert.True(premium.StorageBytes > free.StorageBytes); } } diff --git a/JobTrackerApi/Controllers/AiUsageController.cs b/JobTrackerApi/Controllers/AiUsageController.cs index 891f5cf..2ff7cfb 100644 --- a/JobTrackerApi/Controllers/AiUsageController.cs +++ b/JobTrackerApi/Controllers/AiUsageController.cs @@ -22,7 +22,7 @@ public sealed class AiUsageController : ControllerBase } public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens); - public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime, string Plan, int MonthlyCallLimit); + public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime, string Plan, int MonthlyCallLimit, long MonthlyTokenLimit); [HttpGet] public async Task> Get(CancellationToken cancellationToken) @@ -37,7 +37,8 @@ public sealed class AiUsageController : ControllerBase await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken), await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken), entitlements.AdvancedAi ? "premium" : "free", - entitlements.MonthlyAiCalls)); + entitlements.MonthlyAiCalls, + entitlements.MonthlyAiTokens)); } private static async Task SumAsync(IQueryable query, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Controllers/AiWorkspaceController.cs b/JobTrackerApi/Controllers/AiWorkspaceController.cs index 8e45aec..7f47e2b 100644 --- a/JobTrackerApi/Controllers/AiWorkspaceController.cs +++ b/JobTrackerApi/Controllers/AiWorkspaceController.cs @@ -44,10 +44,17 @@ public sealed class AiWorkspaceController : ControllerBase if (_db is not null) { var roles = await _users.GetRolesAsync(user); - var limit = AccountPlans.ForRoles(roles).MonthlyAiCalls; + var entitlements = AccountPlans.ForRoles(roles); var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero); - var used = await _db.AiInteractions.CountAsync(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart, ct); - if (used >= limit) return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({limit} generations). Upgrade your plan or try again next month."); + var used = await _db.AiInteractions + .Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart) + .GroupBy(_ => 1) + .Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) }) + .FirstOrDefaultAsync(ct); + if ((used?.Calls ?? 0) >= entitlements.MonthlyAiCalls) + return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({entitlements.MonthlyAiCalls} generations). Upgrade your plan or try again next month."); + if ((used?.Tokens ?? 0) >= entitlements.MonthlyAiTokens) + return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI cost limit reached ({entitlements.MonthlyAiTokens:N0} estimated tokens). Upgrade your plan or try again next month."); } try diff --git a/Models/AccountPlans.cs b/Models/AccountPlans.cs index c256a8c..49375e8 100644 --- a/Models/AccountPlans.cs +++ b/Models/AccountPlans.cs @@ -1,6 +1,6 @@ namespace JobTrackerApi.Models; -public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes, int MonthlyAiCalls); +public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes, int MonthlyAiCalls, long MonthlyAiTokens); public static class AccountPlans { @@ -8,7 +8,7 @@ public static class AccountPlans { var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase); return premium - ? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250) - : new AccountEntitlements(false, false, false, false, 250_000_000, 25); + ? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250, 1_000_000) + : new AccountEntitlements(false, false, false, false, 250_000_000, 25, 100_000); } } diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index e6d461d..0039fa4 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -197,7 +197,7 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed. | 7.6 | ✅ **DONE (2026-07-30)** — public CV (`/cv/{guid}`), privacy-first random links, revoke/rotate sharing | **P3** | **M** | 3.4, 4.2 | Anonymous rendering is isolated behind an explicit public flag, served with `noindex`, and revoked links cannot be restored accidentally. | | 7.7 | ✅ **DONE (2026-07-30)** — three free CV themes plus five Premium themes, enforced by account entitlement and clearly locked in the picker | **P3** | **S** | 4.3, 7.2 | Existing Premium-theme CVs remain editable and exportable after downgrade so user data is never held hostage. | | 7.8 | **DONE (2026-07-30)** — CI runs NuGet transitive vulnerability reporting and a production-only npm audit. The npm audit reports the existing no-fix advisory baseline without blocking unrelated deploys. | **P2** | **S** | none | Vulnerable dependencies are now visible before deployment. | -| 7.9 | **Per-user AI provider cost controls** | **P3** | **S** | 5.2, 7.2 | With `AI_PROVIDER=gemini` the "advanced AI" tier spends real money per call. Metering (5.2) measures; this enforces. | +| 7.9 | ✅ **DONE (2026-07-30)** — per-user monthly AI token ceilings (100k free, 1M Premium/Admin) enforced alongside generation limits and exposed in usage totals | **P3** | **S** | 5.2, 7.2 | Existing metering is the single accounting source; paid-provider spend now has both request and token ceilings. | ---