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
- <h2 className="text-sm font-semibold text-slate-700">Derniers mouvements de crédits</h2>
468
- <p className="text-xs text-slate-400 mt-0.5">Les 20 dernières opérations sur votre solde</p>
 
 
 
 
 
 
 
 
 
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);