| 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<void> { |
| 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; |
| |
| 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'); |
| } |
| } |
|
|
| |
| const COST_PER_1M_IN: Record<string, number> = { |
| GEMINI: 0.15, |
| GEMINI_TENANT: 0.15, |
| OPENAI: 2.50, |
| OPENAI_TENANT: 2.50, |
| MOCK: 0, |
| }; |
| const COST_PER_1M_OUT: Record<string, number> = { |
| GEMINI: 0.60, |
| GEMINI_TENANT: 0.60, |
| OPENAI: 10.00, |
| OPENAI_TENANT: 10.00, |
| MOCK: 0, |
| }; |
| |
| 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; |
| tokensIn: number; |
| tokensOut: number; |
| } |
|
|
| export interface TrackAudioUsageParams { |
| organizationId: string; |
| userId?: string; |
| durationMs: number; |
| byok?: boolean; |
| } |
|
|
| export async function trackAiUsage(params: TrackAiUsageParams): Promise<void> { |
| 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, |
| }); |
|
|
| |
| maybeQueueQuotaAlert(organizationId, updatedOrg.aiCreditsUsed - 1); |
| } catch (err) { |
| logger.error({ err }, '[USAGE-TRACKER] Failed to track AI usage'); |
| } |
| } |
|
|
| export async function trackAudioUsage(params: TrackAudioUsageParams): Promise<void> { |
| 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<void> { |
| 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'); |
| } |
| } |
|
|