feat(cost-guard): global Gemini daily budget + EOC alarm; trial requires verified email
This commit is contained in:
+1
-1
@@ -47,7 +47,7 @@ AAMOS_TIMEOUT_MS=60000
|
|||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
GEMINI_MODEL=gemini-2.5-flash
|
GEMINI_MODEL=gemini-2.5-flash
|
||||||
GEMINI_TIMEOUT_MS=60000
|
GEMINI_TIMEOUT_MS=60000
|
||||||
GEMINI_DAILY_BUDGET_USD=10
|
GEMINI_DAILY_BUDGET_USD=25
|
||||||
|
|
||||||
# --- Prenumerationer (spec §45–47) ---
|
# --- Prenumerationer (spec §45–47) ---
|
||||||
APPLE_BUNDLE_ID= # default ur brand.config.json
|
APPLE_BUNDLE_ID= # default ur brand.config.json
|
||||||
|
|||||||
@@ -64,12 +64,6 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
updatedAt: consentNow,
|
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, {
|
await audit(app.db, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: "auth.register",
|
action: "auth.register",
|
||||||
@@ -235,6 +229,20 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
.update(schema.users)
|
.update(schema.users)
|
||||||
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
|
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(schema.users.id, stored.userId));
|
.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 });
|
await audit(app.db, { actorUserId: stored.userId, action: "auth.email_verified", ip: req.ip });
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class RedisBudgetStore implements BudgetStore {
|
|||||||
private readonly key: string,
|
private readonly key: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private alarmKey(): string {
|
||||||
|
return this.key.replace(/^gemini:daily:budget:/, "gemini:daily:budget:alarm:");
|
||||||
|
}
|
||||||
|
|
||||||
async getDailySpendUsd(): Promise<number> {
|
async getDailySpendUsd(): Promise<number> {
|
||||||
const val = await this.redis.get(this.key);
|
const val = await this.redis.get(this.key);
|
||||||
return val ? Number(val) : 0;
|
return val ? Number(val) : 0;
|
||||||
@@ -46,6 +50,11 @@ class RedisBudgetStore implements BudgetStore {
|
|||||||
return Number(newVal);
|
return Number(newVal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async raiseBudgetAlarm(): Promise<void> {
|
||||||
|
const ttl = this.secondsUntilMidnightUtc();
|
||||||
|
await this.redis.set(this.alarmKey(), "1", "EX", ttl);
|
||||||
|
}
|
||||||
|
|
||||||
private secondsUntilMidnightUtc(): number {
|
private secondsUntilMidnightUtc(): number {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const midnight = new Date(
|
const midnight = new Date(
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ function mapPayloadForEoc(parsed: Record<string, unknown>): Record<string, unkno
|
|||||||
if (tile.label === "Allergen-brott" && allergenBreach) {
|
if (tile.label === "Allergen-brott" && allergenBreach) {
|
||||||
tone = "crit";
|
tone = "crit";
|
||||||
}
|
}
|
||||||
|
if (tile.label === "AI-budget" && tone === "crit") {
|
||||||
|
// already crit from buildWall
|
||||||
|
}
|
||||||
return { ...tile, tone };
|
return { ...tile, tone };
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -109,11 +109,17 @@ export interface BudgetStore {
|
|||||||
getDailySpendUsd(): Promise<number>;
|
getDailySpendUsd(): Promise<number>;
|
||||||
/** Increment daily spend by amountUsd; return new total. */
|
/** Increment daily spend by amountUsd; return new total. */
|
||||||
incrementDailySpendUsd(amountUsd: number): Promise<number>;
|
incrementDailySpendUsd(amountUsd: number): Promise<number>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In-memory budget store for tests / single-process dev. */
|
/** In-memory budget store for tests / single-process dev. */
|
||||||
export class MemoryBudgetStore implements BudgetStore {
|
export class MemoryBudgetStore implements BudgetStore {
|
||||||
private spend = 0;
|
private spend = 0;
|
||||||
|
private alarmRaised = false;
|
||||||
async getDailySpendUsd(): Promise<number> {
|
async getDailySpendUsd(): Promise<number> {
|
||||||
return this.spend;
|
return this.spend;
|
||||||
}
|
}
|
||||||
@@ -121,8 +127,15 @@ export class MemoryBudgetStore implements BudgetStore {
|
|||||||
this.spend += amountUsd;
|
this.spend += amountUsd;
|
||||||
return this.spend;
|
return this.spend;
|
||||||
}
|
}
|
||||||
|
async raiseBudgetAlarm(): Promise<void> {
|
||||||
|
this.alarmRaised = true;
|
||||||
|
}
|
||||||
|
alarmWasRaised(): boolean {
|
||||||
|
return this.alarmRaised;
|
||||||
|
}
|
||||||
reset() {
|
reset() {
|
||||||
this.spend = 0;
|
this.spend = 0;
|
||||||
|
this.alarmRaised = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,7 +933,11 @@ Språk: ${lang}.`;
|
|||||||
private async isOverBudget(estimateUsd: number): Promise<boolean> {
|
private async isOverBudget(estimateUsd: number): Promise<boolean> {
|
||||||
if (this.cfg.dailyBudgetUsd <= 0 || !this.cfg.budgetStore) return false;
|
if (this.cfg.dailyBudgetUsd <= 0 || !this.cfg.budgetStore) return false;
|
||||||
const current = await this.cfg.budgetStore.getDailySpendUsd();
|
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<void> {
|
private async recordSpend(costUsd: number): Promise<void> {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types";
|
|||||||
import type { Database } from "./client.js";
|
import type { Database } from "./client.js";
|
||||||
import { schema } from "./index.js";
|
import { schema } from "./index.js";
|
||||||
|
|
||||||
|
const BUDGET_KEY_PREFIX = "gemini:daily:budget:";
|
||||||
|
|
||||||
export interface OpsQueueSummary {
|
export interface OpsQueueSummary {
|
||||||
vantande: number;
|
vantande: number;
|
||||||
aktiva: number;
|
aktiva: number;
|
||||||
@@ -554,6 +556,19 @@ function storeBlock(): OpsStoreBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildWall(summary: OpsSummary): OpsWallBlock {
|
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[] = [
|
const tiles: OpsWallTile[] = [
|
||||||
{
|
{
|
||||||
label: "MRR",
|
label: "MRR",
|
||||||
@@ -575,6 +590,11 @@ export function buildWall(summary: OpsSummary): OpsWallBlock {
|
|||||||
value: formatPercent(summary.prenumerationer.konverteringsgrad_30d),
|
value: formatPercent(summary.prenumerationer.konverteringsgrad_30d),
|
||||||
tone: summary.prenumerationer.konverteringsgrad_30d === null ? "warn" : "ok",
|
tone: summary.prenumerationer.konverteringsgrad_30d === null ? "warn" : "ok",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "AI-budget",
|
||||||
|
value: budgetLabel,
|
||||||
|
tone: budgetTone,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Workers",
|
label: "Workers",
|
||||||
value: summary.jobb.workers_ok ? "OK" : "NERE",
|
value: summary.jobb.workers_ok ? "OK" : "NERE",
|
||||||
@@ -630,10 +650,24 @@ async function feedbackBlock(db: Database): Promise<OpsFeedbackBlock> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<OpsSummary> {
|
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
|
||||||
const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options;
|
const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options;
|
||||||
const planPrices = { ...getPlanPrices(), ...(planPricesOverride ?? {}) };
|
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([
|
await Promise.all([
|
||||||
Promise.resolve(appBlock()),
|
Promise.resolve(appBlock()),
|
||||||
economyBlock(db, planPrices),
|
economyBlock(db, planPrices),
|
||||||
@@ -646,6 +680,7 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom
|
|||||||
engagementBlock(db),
|
engagementBlock(db),
|
||||||
paymentBlock(db),
|
paymentBlock(db),
|
||||||
safetyBlock(db),
|
safetyBlock(db),
|
||||||
|
Promise.resolve(alarmBlock(budgetUsd, dailySpendUsd)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const base: OpsSummary = {
|
const base: OpsSummary = {
|
||||||
@@ -661,6 +696,7 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom
|
|||||||
betalning: payment,
|
betalning: payment,
|
||||||
jobb: queueSummary ?? defaultQueueBlock(),
|
jobb: queueSummary ?? defaultQueueBlock(),
|
||||||
sakerhet: safety,
|
sakerhet: safety,
|
||||||
|
larm,
|
||||||
wall: { boards: [] },
|
wall: { boards: [] },
|
||||||
as_of: new Date().toISOString(),
|
as_of: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export interface OpsSafetyBlock {
|
|||||||
export interface OpsWallTile {
|
export interface OpsWallTile {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
tone: "ok" | "warn";
|
tone: "ok" | "warn" | "crit";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpsWallBoard {
|
export interface OpsWallBoard {
|
||||||
@@ -115,6 +115,18 @@ export interface OpsWallBlock {
|
|||||||
boards: OpsWallBoard[];
|
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 {
|
export interface OpsSummary {
|
||||||
app: OpsAppBlock;
|
app: OpsAppBlock;
|
||||||
ekonomi: OpsEconomyBlock;
|
ekonomi: OpsEconomyBlock;
|
||||||
@@ -128,6 +140,7 @@ export interface OpsSummary {
|
|||||||
betalning: OpsPaymentBlock;
|
betalning: OpsPaymentBlock;
|
||||||
jobb: OpsQueueBlock;
|
jobb: OpsQueueBlock;
|
||||||
sakerhet: OpsSafetyBlock;
|
sakerhet: OpsSafetyBlock;
|
||||||
|
larm: OpsAlarmBlock;
|
||||||
wall: OpsWallBlock;
|
wall: OpsWallBlock;
|
||||||
as_of: string;
|
as_of: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user