CognxSafeTrack commited on
Commit ·
a24fb7f
1
Parent(s): 862b2b1
feat: CSV export, monthly report, and quota alert plan filter
Browse files- BillingPage: add "CSV" download button that exports all wallet
transactions (date, type, description, amount, balance) as UTF-8 CSV
- scheduler.ts: add startMonthlyReportScheduler() — cron on 1st of month
07:30 UTC; sends per-org recap of AI calls, WhatsApp messages, top-3
features, credits recharged/spent, and current balance
- index.ts: register startMonthlyReportScheduler() alongside existing crons
- usage-tracker.ts: quota alert (85% threshold) now only fires for GROWTH,
SCALE, and ENTERPRISE plans; STARTER alerts were high-noise / low-value
apps/admin/src/pages/BillingPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState, useEffect, useRef } from 'react';
|
| 2 |
-
import { Brain, MessageCircle, TrendingUp, Send, Loader2, AlertCircle, Zap, X, Flame, HelpCircle } from 'lucide-react';
|
| 3 |
import { api } from '@/lib/api';
|
| 4 |
import { useAuth } from '@/lib/auth';
|
| 5 |
import { useTenant } from '@/lib/tenant';
|
|
@@ -262,6 +262,28 @@ export default function BillingPage() {
|
|
| 262 |
DEBIT_BROADCAST: '📣 Campagne',
|
| 263 |
};
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
return (
|
| 266 |
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
| 267 |
{showRecharge && <RechargeModal onClose={() => setShowRecharge(false)} />}
|
|
@@ -463,9 +485,18 @@ export default function BillingPage() {
|
|
| 463 |
{/* Historique des transactions — simplifié */}
|
| 464 |
{wallet && wallet.transactions.length > 0 && (
|
| 465 |
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
| 466 |
-
<div className="px-6 py-4 border-b border-slate-100">
|
| 467 |
-
<
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
</div>
|
| 470 |
<div className="divide-y divide-slate-50">
|
| 471 |
{wallet.transactions.filter(tx => !tx.byok).map(tx => {
|
|
|
|
| 1 |
import { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Brain, MessageCircle, TrendingUp, Send, Loader2, AlertCircle, Zap, X, Flame, HelpCircle, Download } from 'lucide-react';
|
| 3 |
import { api } from '@/lib/api';
|
| 4 |
import { useAuth } from '@/lib/auth';
|
| 5 |
import { useTenant } from '@/lib/tenant';
|
|
|
|
| 262 |
DEBIT_BROADCAST: '📣 Campagne',
|
| 263 |
};
|
| 264 |
|
| 265 |
+
function downloadTransactionsCSV() {
|
| 266 |
+
if (!wallet?.transactions.length) return;
|
| 267 |
+
const rows = [
|
| 268 |
+
['Date', 'Type', 'Description', 'Montant (crédits)', 'Solde après'],
|
| 269 |
+
...wallet.transactions.map(tx => [
|
| 270 |
+
new Date(tx.createdAt).toLocaleString('fr-FR'),
|
| 271 |
+
TX_LABELS[tx.type] ?? tx.type,
|
| 272 |
+
tx.description ?? '',
|
| 273 |
+
tx.amount.toString(),
|
| 274 |
+
tx.balanceAfter >= 0 ? tx.balanceAfter.toString() : '',
|
| 275 |
+
]),
|
| 276 |
+
];
|
| 277 |
+
const csv = rows.map(r => r.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
|
| 278 |
+
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
| 279 |
+
const url = URL.createObjectURL(blob);
|
| 280 |
+
const a = document.createElement('a');
|
| 281 |
+
a.href = url;
|
| 282 |
+
a.download = `transactions-${new Date().toISOString().slice(0, 10)}.csv`;
|
| 283 |
+
a.click();
|
| 284 |
+
URL.revokeObjectURL(url);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
return (
|
| 288 |
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
| 289 |
{showRecharge && <RechargeModal onClose={() => setShowRecharge(false)} />}
|
|
|
|
| 485 |
{/* Historique des transactions — simplifié */}
|
| 486 |
{wallet && wallet.transactions.length > 0 && (
|
| 487 |
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
| 488 |
+
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
| 489 |
+
<div>
|
| 490 |
+
<h2 className="text-sm font-semibold text-slate-700">Derniers mouvements de crédits</h2>
|
| 491 |
+
<p className="text-xs text-slate-400 mt-0.5">Les 20 dernières opérations sur votre solde</p>
|
| 492 |
+
</div>
|
| 493 |
+
<button
|
| 494 |
+
onClick={downloadTransactionsCSV}
|
| 495 |
+
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded-lg transition-colors border border-slate-200 hover:border-indigo-200"
|
| 496 |
+
>
|
| 497 |
+
<Download className="w-3.5 h-3.5" />
|
| 498 |
+
CSV
|
| 499 |
+
</button>
|
| 500 |
</div>
|
| 501 |
<div className="divide-y divide-slate-50">
|
| 502 |
{wallet.transactions.filter(tx => !tx.byok).map(tx => {
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -258,11 +258,12 @@ const start = async () => {
|
|
| 258 |
logger.info(`🚀 WhatsApp Worker + Bridge listening on port ${PORT}`);
|
| 259 |
|
| 260 |
// Start the daily cron scheduler + token expiry monitor
|
| 261 |
-
const { startDailyScheduler, startTokenExpiryMonitor, startWalletAlertMonitor, startWeeklyReportScheduler, startPedagogyAdvisor } = await import('./scheduler');
|
| 262 |
startDailyScheduler();
|
| 263 |
startTokenExpiryMonitor();
|
| 264 |
startWalletAlertMonitor();
|
| 265 |
startWeeklyReportScheduler();
|
|
|
|
| 266 |
startPedagogyAdvisor();
|
| 267 |
} catch (err) {
|
| 268 |
logger.error('Failed to start worker server:', err);
|
|
|
|
| 258 |
logger.info(`🚀 WhatsApp Worker + Bridge listening on port ${PORT}`);
|
| 259 |
|
| 260 |
// Start the daily cron scheduler + token expiry monitor
|
| 261 |
+
const { startDailyScheduler, startTokenExpiryMonitor, startWalletAlertMonitor, startWeeklyReportScheduler, startMonthlyReportScheduler, startPedagogyAdvisor } = await import('./scheduler');
|
| 262 |
startDailyScheduler();
|
| 263 |
startTokenExpiryMonitor();
|
| 264 |
startWalletAlertMonitor();
|
| 265 |
startWeeklyReportScheduler();
|
| 266 |
+
startMonthlyReportScheduler();
|
| 267 |
startPedagogyAdvisor();
|
| 268 |
} catch (err) {
|
| 269 |
logger.error('Failed to start worker server:', err);
|
apps/whatsapp-worker/src/scheduler.ts
CHANGED
|
@@ -436,3 +436,115 @@ async function analyzeCohort(organizationId: string, orgName: string) {
|
|
| 436 |
}).catch((e: unknown) => logger.error({ e }, '[PEDAGOGY-ADVISOR] Email failed'));
|
| 437 |
}
|
| 438 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
}).catch((e: unknown) => logger.error({ e }, '[PEDAGOGY-ADVISOR] Email failed'));
|
| 437 |
}
|
| 438 |
}
|
| 439 |
+
|
| 440 |
+
/**
|
| 441 |
+
* Monthly report — runs on the 1st of each month at 07:30 UTC.
|
| 442 |
+
* Summarizes the previous month's AI usage, WhatsApp volume, and wallet spend
|
| 443 |
+
* for every active org, then emails the admin.
|
| 444 |
+
*/
|
| 445 |
+
export function startMonthlyReportScheduler() {
|
| 446 |
+
// 1st of every month at 07:30 UTC
|
| 447 |
+
cron.schedule('30 7 1 * *', async () => {
|
| 448 |
+
logger.info('[MONTHLY-REPORT] Generating monthly reports...');
|
| 449 |
+
try {
|
| 450 |
+
const now = new Date();
|
| 451 |
+
const startOfLastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
| 452 |
+
const startOfThisMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
| 453 |
+
const monthLabel = startOfLastMonth.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
| 454 |
+
|
| 455 |
+
const orgs = await prisma.organization.findMany({
|
| 456 |
+
where: { subscriptionStatus: 'ACTIVE' },
|
| 457 |
+
select: { id: true, name: true, walletBalance: true, aiCreditsUsed: true, aiCreditsLimit: true, subscriptionPlan: true },
|
| 458 |
+
});
|
| 459 |
+
|
| 460 |
+
for (const org of orgs) {
|
| 461 |
+
const admin = await findOrgAdminEmail(org.id);
|
| 462 |
+
if (!admin) continue;
|
| 463 |
+
|
| 464 |
+
const [usage, byFeature, topups, debits, whatsappCount] = await Promise.all([
|
| 465 |
+
prisma.usageEvent.aggregate({
|
| 466 |
+
where: { organizationId: org.id, createdAt: { gte: startOfLastMonth, lt: startOfThisMonth }, type: { not: 'WHATSAPP_SENT' } },
|
| 467 |
+
_sum: { costUsd: true, tokensIn: true, tokensOut: true },
|
| 468 |
+
_count: { id: true },
|
| 469 |
+
}),
|
| 470 |
+
prisma.usageEvent.groupBy({
|
| 471 |
+
by: ['feature'],
|
| 472 |
+
where: { organizationId: org.id, createdAt: { gte: startOfLastMonth, lt: startOfThisMonth }, type: { not: 'WHATSAPP_SENT' } },
|
| 473 |
+
_count: { id: true },
|
| 474 |
+
orderBy: { _count: { id: 'desc' } },
|
| 475 |
+
take: 3,
|
| 476 |
+
}),
|
| 477 |
+
prisma.walletTransaction.aggregate({
|
| 478 |
+
where: { organizationId: org.id, createdAt: { gte: startOfLastMonth, lt: startOfThisMonth }, amount: { gt: 0 } },
|
| 479 |
+
_sum: { amount: true },
|
| 480 |
+
}),
|
| 481 |
+
prisma.walletTransaction.aggregate({
|
| 482 |
+
where: { organizationId: org.id, createdAt: { gte: startOfLastMonth, lt: startOfThisMonth }, amount: { lt: 0 } },
|
| 483 |
+
_sum: { amount: true },
|
| 484 |
+
}),
|
| 485 |
+
prisma.usageEvent.count({
|
| 486 |
+
where: { organizationId: org.id, createdAt: { gte: startOfLastMonth, lt: startOfThisMonth }, type: 'WHATSAPP_SENT' },
|
| 487 |
+
}),
|
| 488 |
+
]);
|
| 489 |
+
|
| 490 |
+
const aiCalls = usage._count.id;
|
| 491 |
+
const costFcfa = Math.round((usage._sum.costUsd ?? 0) * 600);
|
| 492 |
+
const credited = topups._sum.amount ?? 0;
|
| 493 |
+
const spent = Math.abs(debits._sum.amount ?? 0);
|
| 494 |
+
const topFeats = byFeature.map(f => `${FEATURE_LABELS[f.feature] ?? f.feature} (${f._count.id})`).join(', ') || 'N/A';
|
| 495 |
+
|
| 496 |
+
if (aiCalls === 0 && whatsappCount === 0) continue; // Skip orgs with zero activity
|
| 497 |
+
|
| 498 |
+
await EmailService.sendEmail({
|
| 499 |
+
to: admin.email,
|
| 500 |
+
subject: `📅 Rapport mensuel Xamlé — ${org.name} — ${monthLabel}`,
|
| 501 |
+
htmlContent: `
|
| 502 |
+
<div style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px;">
|
| 503 |
+
<h2 style="color:#1e293b;margin-bottom:4px;">📅 Rapport mensuel — ${monthLabel}</h2>
|
| 504 |
+
<p style="color:#64748b;margin-top:0;">${org.name} · Plan ${org.subscriptionPlan ?? 'STARTER'}</p>
|
| 505 |
+
<table style="width:100%;border-collapse:collapse;margin:20px 0;font-size:0.95rem;">
|
| 506 |
+
<tr style="background:#f8fafc;">
|
| 507 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Appels IA</td>
|
| 508 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;font-weight:600;">${aiCalls.toLocaleString('fr-FR')}</td>
|
| 509 |
+
</tr>
|
| 510 |
+
<tr>
|
| 511 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Messages WhatsApp envoyés</td>
|
| 512 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;font-weight:600;">${whatsappCount.toLocaleString('fr-FR')}</td>
|
| 513 |
+
</tr>
|
| 514 |
+
<tr style="background:#f8fafc;">
|
| 515 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Fonctionnalités top 3</td>
|
| 516 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">${topFeats}</td>
|
| 517 |
+
</tr>
|
| 518 |
+
<tr>
|
| 519 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Coût IA (estimation)</td>
|
| 520 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;font-weight:600;">${costFcfa.toLocaleString('fr-FR')} FCFA</td>
|
| 521 |
+
</tr>
|
| 522 |
+
<tr style="background:#f8fafc;">
|
| 523 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Crédits rechargés</td>
|
| 524 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;font-weight:600;color:#059669;">+${credited.toLocaleString('fr-FR')} crédits</td>
|
| 525 |
+
</tr>
|
| 526 |
+
<tr>
|
| 527 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Crédits consommés</td>
|
| 528 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;font-weight:600;color:#dc2626;">-${spent.toLocaleString('fr-FR')} crédits</td>
|
| 529 |
+
</tr>
|
| 530 |
+
<tr style="background:#f8fafc;">
|
| 531 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;">Solde actuel</td>
|
| 532 |
+
<td style="padding:10px 14px;border:1px solid #e2e8f0;font-weight:600;color:${org.walletBalance < 200 ? '#dc2626' : '#059669'};">${org.walletBalance.toLocaleString('fr-FR')} crédits</td>
|
| 533 |
+
</tr>
|
| 534 |
+
</table>
|
| 535 |
+
<a href="https://admin.xamle.studio/billing" style="display:inline-block;padding:12px 24px;background:#4f46e5;color:white;text-decoration:none;border-radius:8px;font-weight:bold;">Voir le détail complet</a>
|
| 536 |
+
<p style="color:#94a3b8;font-size:0.8rem;margin-top:32px;">Ce rapport est généré automatiquement le 1er de chaque mois. · L'équipe Xamlé Studio</p>
|
| 537 |
+
</div>`,
|
| 538 |
+
}).catch(e => logger.error({ e, orgId: org.id }, '[MONTHLY-REPORT] Email failed'));
|
| 539 |
+
|
| 540 |
+
logger.info({ orgId: org.id }, '[MONTHLY-REPORT] Report sent');
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
logger.info('[MONTHLY-REPORT] All monthly reports sent.');
|
| 544 |
+
} catch (err) {
|
| 545 |
+
logger.error({ err }, '[MONTHLY-REPORT] Failed');
|
| 546 |
+
}
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
logger.info('[MONTHLY-REPORT] Monthly report scheduler initialized (cron: 1st of month 07:30 UTC).');
|
| 550 |
+
}
|
apps/whatsapp-worker/src/services/usage-tracker.ts
CHANGED
|
@@ -10,9 +10,12 @@ async function maybeQueueQuotaAlert(organizationId: string, creditsBefore: numbe
|
|
| 10 |
try {
|
| 11 |
const org = await prisma.organization.findUnique({
|
| 12 |
where: { id: organizationId },
|
| 13 |
-
select: { aiCreditsUsed: true, aiCreditsLimit: true, name: true, users: { where: { role: 'ADMIN' }, select: { email: true, name: true }, take: 1 } }
|
| 14 |
});
|
| 15 |
if (!org || org.aiCreditsLimit === 0) return;
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
const pctBefore = Math.floor((creditsBefore / org.aiCreditsLimit) * 100);
|
| 18 |
const pctNow = Math.floor((org.aiCreditsUsed / org.aiCreditsLimit) * 100);
|
|
|
|
| 10 |
try {
|
| 11 |
const org = await prisma.organization.findUnique({
|
| 12 |
where: { id: organizationId },
|
| 13 |
+
select: { aiCreditsUsed: true, aiCreditsLimit: true, subscriptionPlan: true, name: true, users: { where: { role: 'ADMIN' }, select: { email: true, name: true }, take: 1 } }
|
| 14 |
});
|
| 15 |
if (!org || org.aiCreditsLimit === 0) return;
|
| 16 |
+
// Only notify paying plans — STARTER quota alerts add noise, not value
|
| 17 |
+
const notifiablePlans = ['SCALE', 'ENTERPRISE', 'GROWTH'];
|
| 18 |
+
if (!notifiablePlans.includes(org.subscriptionPlan ?? '')) return;
|
| 19 |
|
| 20 |
const pctBefore = Math.floor((creditsBefore / org.aiCreditsLimit) * 100);
|
| 21 |
const pctNow = Math.floor((org.aiCreditsUsed / org.aiCreditsLimit) * 100);
|