import { prisma } from './prisma'; import { logger } from '../logger'; import { notificationQueue } from '../lib/queues'; import { debitWallet } from './wallet'; import { CREDIT_PRICE } from '@repo/database'; const QUOTA_ALERT_THRESHOLD = 85; async function maybeQueueQuotaAlert(organizationId: string, creditsBefore: number): Promise { try { const org = await prisma.organization.findUnique({ where: { id: organizationId }, select: { aiCreditsUsed: true, aiCreditsLimit: true, subscriptionPlan: true, name: true, users: { where: { role: 'ADMIN' }, select: { email: true, name: true }, take: 1 } } }); if (!org || org.aiCreditsLimit === 0) return; // Only notify paying plans — STARTER quota alerts add noise, not value const notifiablePlans = ['SCALE', 'ENTERPRISE', 'GROWTH']; if (!notifiablePlans.includes(org.subscriptionPlan ?? '')) return; const pctBefore = Math.floor((creditsBefore / org.aiCreditsLimit) * 100); const pctNow = Math.floor((org.aiCreditsUsed / org.aiCreditsLimit) * 100); if (pctBefore < QUOTA_ALERT_THRESHOLD && pctNow >= QUOTA_ALERT_THRESHOLD) { const admin = org.users[0]; if (admin?.email) { await notificationQueue.add('send-email', { to: admin.email, templateId: 3, params: { name: admin.name ?? 'Admin', orgName: org.name, percentUsed: pctNow, creditsUsed: org.aiCreditsUsed, creditsLimit: org.aiCreditsLimit, }, }); } await notificationQueue.add('send-push', { organizationId, title: `⚠️ Quota IA — ${pctNow}% utilisé`, body: `${org.name} a consommé ${pctNow}% de ses crédits IA ce mois-ci.`, data: { type: 'quota_alert', percentUsed: pctNow }, }); logger.info({ organizationId, pctNow }, '[USAGE-TRACKER] Quota alert queued (email + push)'); } } catch (err) { logger.error({ err }, '[USAGE-TRACKER] Failed to check/queue quota alert'); } } // Cost per 1M tokens in USD (Gemini 2.5 Flash / OpenAI GPT-4o) const COST_PER_1M_IN: Record = { GEMINI: 0.15, GEMINI_TENANT: 0.15, OPENAI: 2.50, OPENAI_TENANT: 2.50, MOCK: 0, }; const COST_PER_1M_OUT: Record = { GEMINI: 0.60, GEMINI_TENANT: 0.60, OPENAI: 10.00, OPENAI_TENANT: 10.00, MOCK: 0, }; // Whisper: $0.006 per minute, we store durationMs and compute accordingly const WHISPER_COST_PER_MS = 0.006 / 60_000; export type UsageEventType = 'AI_TEXT' | 'AI_AUDIO' | 'AI_IMAGE' | 'WHATSAPP_SENT'; export type UsageProvider = 'GEMINI' | 'OPENAI' | 'META'; export type UsageFeature = 'LESSON' | 'FEEDBACK' | 'DEEPDIVE' | 'TRANSCRIPTION' | 'IMAGE_ANALYSIS' | 'SPEECH' | 'CAMPAIGN' | 'KB_QUERY' | 'ONBOARDING' | 'OTHER'; export interface TrackAiUsageParams { organizationId: string; userId?: string; feature: UsageFeature; aiSource: string; // e.g. "GEMINI", "OPENAI", "GEMINI_TENANT" tokensIn: number; tokensOut: number; } export interface TrackAudioUsageParams { organizationId: string; userId?: string; durationMs: number; byok?: boolean; } export async function trackAiUsage(params: TrackAiUsageParams): Promise { try { const { organizationId, userId, feature, aiSource, tokensIn, tokensOut } = params; const priceIn = COST_PER_1M_IN[aiSource] ?? 0.15; const priceOut = COST_PER_1M_OUT[aiSource] ?? 0.60; const costUsd = (tokensIn / 1_000_000) * priceIn + (tokensOut / 1_000_000) * priceOut; const provider: UsageProvider = aiSource.includes('OPENAI') ? 'OPENAI' : 'GEMINI'; const isByok = aiSource.includes('_TENANT'); const [, updatedOrg] = await Promise.all([ prisma.usageEvent.create({ data: { organizationId, userId, type: 'AI_TEXT', provider, feature, tokensIn, tokensOut, costUsd }, }), prisma.organization.update({ where: { id: organizationId }, data: { aiCreditsUsed: { increment: 1 } }, select: { aiCreditsUsed: true }, }), ]); await debitWallet({ organizationId, amount: CREDIT_PRICE.AI_TEXT, type: 'DEBIT_AI', description: `AI text — ${feature}`, actorId: userId, byok: isByok, }); // Fire quota alert when crossing 85% threshold (fire-and-forget) maybeQueueQuotaAlert(organizationId, updatedOrg.aiCreditsUsed - 1); } catch (err) { logger.error({ err }, '[USAGE-TRACKER] Failed to track AI usage'); } } export async function trackAudioUsage(params: TrackAudioUsageParams): Promise { try { const { organizationId, userId, durationMs, byok = false } = params; const costUsd = durationMs * WHISPER_COST_PER_MS; await Promise.all([ prisma.usageEvent.create({ data: { organizationId, userId, type: 'AI_AUDIO', provider: 'OPENAI', feature: 'TRANSCRIPTION', tokensIn: 0, tokensOut: 0, durationMs, costUsd, }, }), prisma.organization.update({ where: { id: organizationId }, data: { aiCreditsUsed: { increment: 1 } }, }), ]); await debitWallet({ organizationId, amount: CREDIT_PRICE.AI_AUDIO, type: 'DEBIT_AI', description: 'Audio transcription', actorId: userId, byok, }); } catch (err) { logger.error({ err }, '[USAGE-TRACKER] Failed to track audio usage'); } } export async function trackWhatsAppSent(organizationId: string, count: number = 1): Promise { try { await Promise.all([ prisma.usageEvent.create({ data: { organizationId, type: 'WHATSAPP_SENT', provider: 'META', feature: 'OTHER', tokensIn: 0, tokensOut: 0, costUsd: 0, }, }), prisma.organization.update({ where: { id: organizationId }, data: { whatsappMessagesSent: { increment: count } }, }), ]); await debitWallet({ organizationId, amount: CREDIT_PRICE.WHATSAPP_CONVERSATION * count, type: 'DEBIT_WHATSAPP', description: `WhatsApp message${count > 1 ? ` x${count}` : ''}`, }); } catch (err) { logger.error({ err }, '[USAGE-TRACKER] Failed to track WhatsApp message'); } }