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 +55 -0
- apps/api/src/routes/billing.ts +8 -0
- apps/whatsapp-worker/src/handlers/AdminHandler.ts +1 -1
- apps/whatsapp-worker/src/handlers/ContentHandler.ts +3 -2
- apps/whatsapp-worker/src/handlers/EnrollHandler.ts +3 -3
- apps/whatsapp-worker/src/handlers/MediaHandler.ts +2 -2
- apps/whatsapp-worker/src/handlers/NudgeHandler.ts +1 -1
- apps/whatsapp-worker/src/whatsapp-cloud.ts +5 -1
|
@@ -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">
|
|
@@ -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 |
|
|
@@ -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: {
|
|
@@ -81,7 +81,8 @@ export class ContentHandler implements JobHandler {
|
|
| 81 |
|
| 82 |
if (user?.phone) {
|
| 83 |
const tenantConfig = await this.getTenantConfig(user.organizationId);
|
| 84 |
-
|
|
|
|
| 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'],
|
| 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 |
}
|
|
@@ -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 });
|
|
@@ -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 |
}
|
|
@@ -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 |
}
|
|
@@ -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 |
/**
|