From 6c581bec1a010c96fcf9e2553dcf96ad27b98cc7 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Wed, 12 Aug 2026 06:43:32 +0700 Subject: [PATCH] feat(cost-guard): global Gemini daily budget + EOC alarm; trial requires verified email --- .env.example | 2 +- apps/api/src/routes/auth.ts | 20 ++++++++++---- apps/worker/src/context.ts | 9 ++++++ apps/worker/src/processors/eoc-push.ts | 3 ++ packages/ai-contracts/src/gemini.ts | 19 ++++++++++++- packages/database/src/ops-summary.ts | 38 +++++++++++++++++++++++++- packages/shared-types/src/ops.ts | 15 +++++++++- 7 files changed, 96 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 6a9cba3..c2c2003 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,7 @@ AAMOS_TIMEOUT_MS=60000 GEMINI_API_KEY= GEMINI_MODEL=gemini-2.5-flash GEMINI_TIMEOUT_MS=60000 -GEMINI_DAILY_BUDGET_USD=10 +GEMINI_DAILY_BUDGET_USD=25 # --- Prenumerationer (spec §45–47) --- APPLE_BUNDLE_ID= # default ur brand.config.json diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 96d12b1..35c43d9 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -64,12 +64,6 @@ export async function authRoutes(app: FastifyInstance) { updatedAt: consentNow, }); - // 7 dagars trial utan kort startar direkt (spec §46) - const now = new Date(); - await app.db - .insert(schema.trials) - .values({ userId: user.id, startedAt: now, endsAt: trialEndsAt(now) }); - await audit(app.db, { actorUserId: user.id, action: "auth.register", @@ -235,6 +229,20 @@ export async function authRoutes(app: FastifyInstance) { .update(schema.users) .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) .where(eq(schema.users.id, stored.userId)); + + // Trial startas först när e-posten är verifierad. En trial per verifierad e-post. + const [existingTrial] = await app.db + .select({ id: schema.trials.id }) + .from(schema.trials) + .where(eq(schema.trials.userId, stored.userId)) + .limit(1); + if (!existingTrial) { + const now = new Date(); + await app.db + .insert(schema.trials) + .values({ userId: stored.userId, startedAt: now, endsAt: trialEndsAt(now) }); + } + await audit(app.db, { actorUserId: stored.userId, action: "auth.email_verified", ip: req.ip }); return reply.send({ ok: true }); }); diff --git a/apps/worker/src/context.ts b/apps/worker/src/context.ts index 3411817..6f4ec39 100644 --- a/apps/worker/src/context.ts +++ b/apps/worker/src/context.ts @@ -33,6 +33,10 @@ class RedisBudgetStore implements BudgetStore { private readonly key: string, ) {} + private alarmKey(): string { + return this.key.replace(/^gemini:daily:budget:/, "gemini:daily:budget:alarm:"); + } + async getDailySpendUsd(): Promise { const val = await this.redis.get(this.key); return val ? Number(val) : 0; @@ -46,6 +50,11 @@ class RedisBudgetStore implements BudgetStore { return Number(newVal); } + async raiseBudgetAlarm(): Promise { + const ttl = this.secondsUntilMidnightUtc(); + await this.redis.set(this.alarmKey(), "1", "EX", ttl); + } + private secondsUntilMidnightUtc(): number { const now = new Date(); const midnight = new Date( diff --git a/apps/worker/src/processors/eoc-push.ts b/apps/worker/src/processors/eoc-push.ts index 5b01b79..9eb9df0 100644 --- a/apps/worker/src/processors/eoc-push.ts +++ b/apps/worker/src/processors/eoc-push.ts @@ -102,6 +102,9 @@ function mapPayloadForEoc(parsed: Record): Record; /** Increment daily spend by amountUsd; return new total. */ incrementDailySpendUsd(amountUsd: number): Promise; + /** + * Optional: called when the daily budget is exceeded. Implementations can + * raise an alert (e.g. write to Redis / DB) so ops-summary/EOC can surface it. + */ + raiseBudgetAlarm?(): Promise; } /** In-memory budget store for tests / single-process dev. */ export class MemoryBudgetStore implements BudgetStore { private spend = 0; + private alarmRaised = false; async getDailySpendUsd(): Promise { return this.spend; } @@ -121,8 +127,15 @@ export class MemoryBudgetStore implements BudgetStore { this.spend += amountUsd; return this.spend; } + async raiseBudgetAlarm(): Promise { + this.alarmRaised = true; + } + alarmWasRaised(): boolean { + return this.alarmRaised; + } reset() { this.spend = 0; + this.alarmRaised = false; } } @@ -920,7 +933,11 @@ Språk: ${lang}.`; private async isOverBudget(estimateUsd: number): Promise { if (this.cfg.dailyBudgetUsd <= 0 || !this.cfg.budgetStore) return false; const current = await this.cfg.budgetStore.getDailySpendUsd(); - return current + estimateUsd > this.cfg.dailyBudgetUsd; + const over = current + estimateUsd > this.cfg.dailyBudgetUsd; + if (over && this.cfg.budgetStore.raiseBudgetAlarm) { + await this.cfg.budgetStore.raiseBudgetAlarm(); + } + return over; } private async recordSpend(costUsd: number): Promise { diff --git a/packages/database/src/ops-summary.ts b/packages/database/src/ops-summary.ts index 34f1170..816887b 100644 --- a/packages/database/src/ops-summary.ts +++ b/packages/database/src/ops-summary.ts @@ -20,6 +20,8 @@ import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types"; import type { Database } from "./client.js"; import { schema } from "./index.js"; +const BUDGET_KEY_PREFIX = "gemini:daily:budget:"; + export interface OpsQueueSummary { vantande: number; aktiva: number; @@ -554,6 +556,19 @@ function storeBlock(): OpsStoreBlock { } export function buildWall(summary: OpsSummary): OpsWallBlock { + const budgetUsd = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 0); + const dailySpendUsd = summary.ai_scan.budget_andel != null && budgetUsd > 0 + ? summary.ai_scan.budget_andel * budgetUsd + : null; + const budgetLabel = dailySpendUsd != null && budgetUsd > 0 + ? `${dailySpendUsd.toFixed(2)} / ${budgetUsd} USD` + : "—"; + const budgetTone: OpsWallTile["tone"] = + budgetUsd <= 0 ? "ok" : + summary.ai_scan.budget_andel == null ? "warn" : + summary.ai_scan.budget_andel >= 1 ? "crit" : + summary.ai_scan.budget_andel >= 0.8 ? "warn" : "ok"; + const tiles: OpsWallTile[] = [ { label: "MRR", @@ -575,6 +590,11 @@ export function buildWall(summary: OpsSummary): OpsWallBlock { value: formatPercent(summary.prenumerationer.konverteringsgrad_30d), tone: summary.prenumerationer.konverteringsgrad_30d === null ? "warn" : "ok", }, + { + label: "AI-budget", + value: budgetLabel, + tone: budgetTone, + }, { label: "Workers", value: summary.jobb.workers_ok ? "OK" : "NERE", @@ -630,10 +650,24 @@ async function feedbackBlock(db: Database): Promise { }; } +function alarmBlock(budgetUsd: number, dailySpendUsd: number | null | undefined): OpsAlarmBlock { + const alarms: OpsAlarm[] = []; + if (budgetUsd > 0 && dailySpendUsd != null && dailySpendUsd >= budgetUsd) { + alarms.push({ + typ: "budget_exceeded", + rubrik: "Gemini dagsbudget förbrukad", + detalj: `Dagens spend ${dailySpendUsd.toFixed(4)} USD når eller överskrider taket ${budgetUsd} USD. Inga nya AI-jobb körs tills imorgon.`, + allvarlighetsgrad: "crit", + created_at: new Date().toISOString(), + }); + } + return { larm: alarms }; +} + export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise { const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options; const planPrices = { ...getPlanPrices(), ...(planPricesOverride ?? {}) }; - const [app, economy, users, subscriptions, butik, feedbackData, ai, activation, engagement, payment, safety] = + const [app, economy, users, subscriptions, butik, feedbackData, ai, activation, engagement, payment, safety, larm] = await Promise.all([ Promise.resolve(appBlock()), economyBlock(db, planPrices), @@ -646,6 +680,7 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom engagementBlock(db), paymentBlock(db), safetyBlock(db), + Promise.resolve(alarmBlock(budgetUsd, dailySpendUsd)), ]); const base: OpsSummary = { @@ -661,6 +696,7 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom betalning: payment, jobb: queueSummary ?? defaultQueueBlock(), sakerhet: safety, + larm, wall: { boards: [] }, as_of: new Date().toISOString(), }; diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts index f59edc8..cdab7c0 100644 --- a/packages/shared-types/src/ops.ts +++ b/packages/shared-types/src/ops.ts @@ -103,7 +103,7 @@ export interface OpsSafetyBlock { export interface OpsWallTile { label: string; value: string; - tone: "ok" | "warn"; + tone: "ok" | "warn" | "crit"; } export interface OpsWallBoard { @@ -115,6 +115,18 @@ export interface OpsWallBlock { boards: OpsWallBoard[]; } +export interface OpsAlarm { + typ: string; + rubrik: string; + detalj?: string; + allvarlighetsgrad: "warn" | "crit"; + created_at: string; +} + +export interface OpsAlarmBlock { + larm: OpsAlarm[]; +} + export interface OpsSummary { app: OpsAppBlock; ekonomi: OpsEconomyBlock; @@ -128,6 +140,7 @@ export interface OpsSummary { betalning: OpsPaymentBlock; jobb: OpsQueueBlock; sakerhet: OpsSafetyBlock; + larm: OpsAlarmBlock; wall: OpsWallBlock; as_of: string; }