edtech / apps /whatsapp-worker /src /services /usage-tracker.ts
CognxSafeTrack
feat: CSV export, monthly report, and quota alert plan filter
a24fb7f
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;
// 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<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,
};
// 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<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,
});
// 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<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');
}
}