CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
f5126bc
·
1 Parent(s): 0fd3320

feat(billing): close 3 remaining wallet gaps from implementation plan

Browse files

⑫ sendTextMessage() wallet debit coverage:
- Add organizationId?: string to sendTextMessage() config — calls trackWhatsAppSent() automatically after each successful send
- NudgeHandler, ContentHandler, EnrollHandler, MediaHandler, AdminHandler: pass organizationId in tenantConfig so every outbound message triggers the debit without touching each callsite individually

⑯ GET /billing/summary now includes wallet block:
- wallet: { balance, isHardStopped, creditToFcfa, balanceFcfa } appended to every summary response

⑱ BillingPage: wallet transaction table:
- Table showing last 20 transactions (date, type, description, amount, balance after)
- Type labels with emoji (recharge vs debit), BYOK badge, red/green amount colouring
- Inserted between feature breakdown and chat widget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/admin/src/pages/BillingPage.tsx CHANGED
@@ -443,6 +443,61 @@ export default function BillingPage() {
443
  </div>
444
  )}
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  {/* AI Chat Widget */}
447
  <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
448
  <div className="px-6 py-4 border-b border-slate-100 bg-slate-50">
 
443
  </div>
444
  )}
445
 
446
+ {/* Wallet transactions */}
447
+ {wallet && wallet.transactions.length > 0 && (
448
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
449
+ <div className="px-6 py-4 border-b border-slate-100">
450
+ <h2 className="text-sm font-semibold text-slate-700">Dernières transactions wallet</h2>
451
+ </div>
452
+ <div className="overflow-x-auto">
453
+ <table className="w-full text-sm">
454
+ <thead>
455
+ <tr className="border-b border-slate-100 text-xs text-slate-400 uppercase">
456
+ <th className="px-6 py-3 text-left font-semibold">Date</th>
457
+ <th className="px-6 py-3 text-left font-semibold">Type</th>
458
+ <th className="px-6 py-3 text-left font-semibold">Description</th>
459
+ <th className="px-6 py-3 text-right font-semibold">Montant</th>
460
+ <th className="px-6 py-3 text-right font-semibold">Solde après</th>
461
+ </tr>
462
+ </thead>
463
+ <tbody>
464
+ {wallet.transactions.map(tx => {
465
+ const isCredit = tx.amount > 0;
466
+ const typeLabels: Record<string, string> = {
467
+ TOP_UP_MANUAL: '➕ Recharge manuelle',
468
+ TOP_UP_PAYMENT: '➕ Paiement',
469
+ ADJUSTMENT: '🔧 Ajustement',
470
+ DEBIT_AI: '🤖 IA',
471
+ DEBIT_WHATSAPP: '💬 WhatsApp',
472
+ DEBIT_BROADCAST: '📣 Broadcast',
473
+ };
474
+ return (
475
+ <tr key={tx.id} className="border-b border-slate-50 hover:bg-slate-50 transition-colors">
476
+ <td className="px-6 py-3 text-slate-500 whitespace-nowrap">
477
+ {new Date(tx.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
478
+ </td>
479
+ <td className="px-6 py-3 text-slate-600 whitespace-nowrap">
480
+ {typeLabels[tx.type] ?? tx.type}
481
+ {tx.byok && <span className="ml-1 text-xs text-slate-400">(BYOK)</span>}
482
+ </td>
483
+ <td className="px-6 py-3 text-slate-500 max-w-[200px] truncate">
484
+ {tx.description ?? '—'}
485
+ </td>
486
+ <td className={`px-6 py-3 text-right font-mono font-semibold whitespace-nowrap ${isCredit ? 'text-emerald-600' : 'text-slate-700'}`}>
487
+ {isCredit ? '+' : ''}{tx.amount.toLocaleString('fr-FR')} cr
488
+ </td>
489
+ <td className="px-6 py-3 text-right font-mono text-slate-500 whitespace-nowrap">
490
+ {tx.balanceAfter >= 0 ? tx.balanceAfter.toLocaleString('fr-FR') : '—'}
491
+ </td>
492
+ </tr>
493
+ );
494
+ })}
495
+ </tbody>
496
+ </table>
497
+ </div>
498
+ </div>
499
+ )}
500
+
501
  {/* AI Chat Widget */}
502
  <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
503
  <div className="px-6 py-4 border-b border-slate-100 bg-slate-50">
apps/api/src/routes/billing.ts CHANGED
@@ -27,6 +27,8 @@ export async function billingRoutes(fastify: FastifyInstance) {
27
  aiCreditsLimit: true,
28
  whatsappMessagesSent: true,
29
  billingPeriodStart: true,
 
 
30
  }
31
  });
32
  if (!org) return reply.code(404).send({ error: 'Organization not found' });
@@ -67,6 +69,12 @@ export async function billingRoutes(fastify: FastifyInstance) {
67
  messagesSent: org.whatsappMessagesSent,
68
  note: 'Facturé directement par Meta sur votre compte WABA',
69
  },
 
 
 
 
 
 
70
  };
71
  });
72
 
 
27
  aiCreditsLimit: true,
28
  whatsappMessagesSent: true,
29
  billingPeriodStart: true,
30
+ walletBalance: true,
31
+ isHardStopped: true,
32
  }
33
  });
34
  if (!org) return reply.code(404).send({ error: 'Organization not found' });
 
69
  messagesSent: org.whatsappMessagesSent,
70
  note: 'Facturé directement par Meta sur votre compte WABA',
71
  },
72
+ wallet: {
73
+ balance: org.walletBalance,
74
+ isHardStopped: org.isHardStopped,
75
+ creditToFcfa: 10,
76
+ balanceFcfa: org.walletBalance * 10,
77
+ },
78
  };
79
  });
80
 
apps/whatsapp-worker/src/handlers/AdminHandler.ts CHANGED
@@ -34,7 +34,7 @@ export class AdminHandler implements JobHandler {
34
  const { sendAudioMessage } = await import('../whatsapp-cloud');
35
  await sendAudioMessage(user.phone, overrideAudioUrl, tenantConfig);
36
  const adminMsg = { FR: "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.", WOLOF: "Baax na ! Yónnee *SUITE* ngir dem ci kanam.", EN: "Well done! Send *CONTINUE* to move to the next lesson.", ES: "¡Bravo! Envía *CONTINUAR* para pasar a la siguiente lección.", PT: "Muito bem! Envie *CONTINUAR* para passar para a próxima lição." }[user.language] ?? "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.";
37
- await sendTextMessage(user.phone, adminMsg, tenantConfig);
38
 
39
  await prisma.response.create({
40
  data: {
 
34
  const { sendAudioMessage } = await import('../whatsapp-cloud');
35
  await sendAudioMessage(user.phone, overrideAudioUrl, tenantConfig);
36
  const adminMsg = { FR: "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.", WOLOF: "Baax na ! Yónnee *SUITE* ngir dem ci kanam.", EN: "Well done! Send *CONTINUE* to move to the next lesson.", ES: "¡Bravo! Envía *CONTINUAR* para pasar a la siguiente lección.", PT: "Muito bem! Envie *CONTINUAR* para passar para a próxima lição." }[user.language] ?? "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.";
37
+ await sendTextMessage(user.phone, adminMsg, { ...tenantConfig, organizationId });
38
 
39
  await prisma.response.create({
40
  data: {
apps/whatsapp-worker/src/handlers/ContentHandler.ts CHANGED
@@ -81,7 +81,8 @@ export class ContentHandler implements JobHandler {
81
 
82
  if (user?.phone) {
83
  const tenantConfig = await this.getTenantConfig(user.organizationId);
84
- await sendTextMessage(user.phone, congratsMsg[lang] ?? congratsMsg['FR'], tenantConfig);
 
85
 
86
  if (!nextTrack.isPremium) {
87
  await prisma.$transaction([
@@ -102,7 +103,7 @@ export class ContentHandler implements JobHandler {
102
  ES: `💳 El Nivel ${nextLevel} es Premium. ¡Envía "PAGAR" para desbloquearlo!`,
103
  PT: `💳 O Nível ${nextLevel} é Premium. Envie "PAGAR" para desbloqueá-lo!`,
104
  };
105
- await sendTextMessage(user.phone, payMsg[lang] ?? payMsg['FR'], tenantConfig);
106
  }
107
  }
108
  }
 
81
 
82
  if (user?.phone) {
83
  const tenantConfig = await this.getTenantConfig(user.organizationId);
84
+ const orgConfig = { ...tenantConfig, organizationId: user.organizationId };
85
+ await sendTextMessage(user.phone, congratsMsg[lang] ?? congratsMsg['FR'], orgConfig);
86
 
87
  if (!nextTrack.isPremium) {
88
  await prisma.$transaction([
 
103
  ES: `💳 El Nivel ${nextLevel} es Premium. ¡Envía "PAGAR" para desbloquearlo!`,
104
  PT: `💳 O Nível ${nextLevel} é Premium. Envie "PAGAR" para desbloqueá-lo!`,
105
  };
106
+ await sendTextMessage(user.phone, payMsg[lang] ?? payMsg['FR'], orgConfig);
107
  }
108
  }
109
  }
apps/whatsapp-worker/src/handlers/EnrollHandler.ts CHANGED
@@ -32,7 +32,7 @@ export class EnrollHandler implements JobHandler {
32
  const tenantConfig = await this.getTenantConfig(organizationId as string);
33
  const msgMap: Record<string, string> = { FR: "La formation est en cours de préparation. Nous te préviendrons dès qu'elle est disponible ! 📚", WOLOF: "Dafa daan jàppale — dinañu la xam bu amee ci kanam ! 📚", EN: "Training content is being prepared. We'll notify you when it's ready! 📚", ES: "El contenido de formación está en preparación. ¡Te avisaremos cuando esté listo! 📚", PT: "O conteúdo da formação está a ser preparado. Iremos notificá-lo quando estiver pronto! 📚" };
34
  const msg = msgMap[user.language] ?? msgMap['FR'];
35
- await sendTextMessage(user.phone, msg, tenantConfig);
36
  }
37
  return;
38
  }
@@ -56,7 +56,7 @@ export class EnrollHandler implements JobHandler {
56
  const user = await prisma.user.findUnique({ where: { id: userId } });
57
  if (user?.phone) {
58
  const tenantConfig = await this.getTenantConfig(organizationId as string);
59
- await sendTextMessage(user.phone, `💳 Cette formation est Premium. Complétez votre paiement ici :\n${checkoutData.url}`, tenantConfig);
60
  }
61
  }
62
  } catch (err) {
@@ -95,7 +95,7 @@ export class EnrollHandler implements JobHandler {
95
  ES: `🎉 ¡Bienvenido a *${track.title}*! Tu lección personalizada (Día 1) está siendo generada...`,
96
  PT: `🎉 Bem-vindo a *${track.title}*! Sua lição personalizada (Dia 1) está sendo gerada...`,
97
  };
98
- await sendTextMessage(user.phone, welcomeMsg[user.language] ?? welcomeMsg['FR'], tenantConfig);
99
 
100
  const q = new Queue('whatsapp-queue', { connection });
101
  await q.add('send-content', { userId, trackId, dayNumber: 1, organizationId });
 
32
  const tenantConfig = await this.getTenantConfig(organizationId as string);
33
  const msgMap: Record<string, string> = { FR: "La formation est en cours de préparation. Nous te préviendrons dès qu'elle est disponible ! 📚", WOLOF: "Dafa daan jàppale — dinañu la xam bu amee ci kanam ! 📚", EN: "Training content is being prepared. We'll notify you when it's ready! 📚", ES: "El contenido de formación está en preparación. ¡Te avisaremos cuando esté listo! 📚", PT: "O conteúdo da formação está a ser preparado. Iremos notificá-lo quando estiver pronto! 📚" };
34
  const msg = msgMap[user.language] ?? msgMap['FR'];
35
+ await sendTextMessage(user.phone, msg, { ...tenantConfig, organizationId: organizationId as string });
36
  }
37
  return;
38
  }
 
56
  const user = await prisma.user.findUnique({ where: { id: userId } });
57
  if (user?.phone) {
58
  const tenantConfig = await this.getTenantConfig(organizationId as string);
59
+ await sendTextMessage(user.phone, `💳 Cette formation est Premium. Complétez votre paiement ici :\n${checkoutData.url}`, { ...tenantConfig, organizationId: organizationId as string });
60
  }
61
  }
62
  } catch (err) {
 
95
  ES: `🎉 ¡Bienvenido a *${track.title}*! Tu lección personalizada (Día 1) está siendo generada...`,
96
  PT: `🎉 Bem-vindo a *${track.title}*! Sua lição personalizada (Dia 1) está sendo gerada...`,
97
  };
98
+ await sendTextMessage(user.phone, welcomeMsg[user.language] ?? welcomeMsg['FR'], { ...tenantConfig, organizationId: organizationId as string });
99
 
100
  const q = new Queue('whatsapp-queue', { connection });
101
  await q.add('send-content', { userId, trackId, dayNumber: 1, organizationId });
apps/whatsapp-worker/src/handlers/MediaHandler.ts CHANGED
@@ -81,7 +81,7 @@ export class MediaHandler implements JobHandler {
81
  FR: "⏳ J'analyse ton audio...", WOLOF: "⏳ Defar ak sa kàddu...",
82
  EN: "⏳ Analysing your audio...", ES: "⏳ Analizando tu audio...", PT: "⏳ Analisando seu áudio..."
83
  }[user.language] ?? "⏳ Analysing...";
84
- await sendTextMessage(phone, spinnerMsg, tenantConfig);
85
  }
86
  }
87
 
@@ -132,7 +132,7 @@ export class MediaHandler implements JobHandler {
132
 
133
  }
134
  });
135
- await sendTextMessage(phone, "🎙️ Nyangi jaxas sa kàddu. Xamle dina la tontu ci kanam !", tenantConfig);
136
  return;
137
  }
138
  }
 
81
  FR: "⏳ J'analyse ton audio...", WOLOF: "⏳ Defar ak sa kàddu...",
82
  EN: "⏳ Analysing your audio...", ES: "⏳ Analizando tu audio...", PT: "⏳ Analisando seu áudio..."
83
  }[user.language] ?? "⏳ Analysing...";
84
+ await sendTextMessage(phone, spinnerMsg, { ...tenantConfig, organizationId: organizationId as string });
85
  }
86
  }
87
 
 
132
 
133
  }
134
  });
135
+ await sendTextMessage(phone, "🎙️ Nyangi jaxas sa kàddu. Xamle dina la tontu ci kanam !", { ...tenantConfig, organizationId: organizationId as string });
136
  return;
137
  }
138
  }
apps/whatsapp-worker/src/handlers/NudgeHandler.ts CHANGED
@@ -35,7 +35,7 @@ export class NudgeHandler implements JobHandler {
35
 
36
  const nudgeType = (type || 'ENCOURAGEMENT') as keyof typeof messages;
37
  const text = messages[nudgeType] || messages.ENCOURAGEMENT;
38
- await sendTextMessage(user.phone, text, tenantConfig);
39
  logger.info(`[NUDGE_HANDLER] Nudge ${nudgeType} sent to ${user.phone}`);
40
  }
41
  }
 
35
 
36
  const nudgeType = (type || 'ENCOURAGEMENT') as keyof typeof messages;
37
  const text = messages[nudgeType] || messages.ENCOURAGEMENT;
38
+ await sendTextMessage(user.phone, text, { ...tenantConfig, organizationId: organizationId || undefined });
39
  logger.info(`[NUDGE_HANDLER] Nudge ${nudgeType} sent to ${user.phone}`);
40
  }
41
  }
apps/whatsapp-worker/src/whatsapp-cloud.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { logger } from './logger';
 
2
  /**
3
  * WhatsApp Cloud API Service
4
  *
@@ -40,7 +41,7 @@ function getHeaders(explicitToken?: string): Record<string, string> {
40
  * @param to - Recipient phone number in international format (e.g. "221771234567")
41
  * @param text - Message body (supports basic WhatsApp markdown: *bold*, _italic_)
42
  */
43
- export async function sendTextMessage(to: string, text: string, config?: { accessToken?: string, phoneNumberId?: string }): Promise<void> {
44
  // Safety guard: HF is inbound-only. Only Railway worker should call this.
45
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
46
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping send to ${to}. Message: "${text.substring(0, 60)}..."`);
@@ -62,6 +63,9 @@ export async function sendTextMessage(to: string, text: string, config?: { acces
62
  }
63
 
64
  logger.info(`[WhatsApp] ✅ Text message sent to ${to}`);
 
 
 
65
  }
66
 
67
  /**
 
1
  import { logger } from './logger';
2
+ import { trackWhatsAppSent } from './services/usage-tracker';
3
  /**
4
  * WhatsApp Cloud API Service
5
  *
 
41
  * @param to - Recipient phone number in international format (e.g. "221771234567")
42
  * @param text - Message body (supports basic WhatsApp markdown: *bold*, _italic_)
43
  */
44
+ export async function sendTextMessage(to: string, text: string, config?: { accessToken?: string, phoneNumberId?: string, organizationId?: string }): Promise<void> {
45
  // Safety guard: HF is inbound-only. Only Railway worker should call this.
46
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
47
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping send to ${to}. Message: "${text.substring(0, 60)}..."`);
 
63
  }
64
 
65
  logger.info(`[WhatsApp] ✅ Text message sent to ${to}`);
66
+ if (config?.organizationId) {
67
+ trackWhatsAppSent(config.organizationId).catch(() => {});
68
+ }
69
  }
70
 
71
  /**