
Fullstack Engineer
- 1 installs
- Updated April 27, 2026
- codexrock/fatura
Build end-to-end features across frontend and backend systems
About
Builds end-to-end features spanning frontend UI, APIs, databases, and deployment. Implements complete systems.
- Full-stack development
- End-to-end features
Fullstack Engineer by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,830 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/codexrock/fatura --skill fullstack-engineerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 27, 2026 |
| Repository | codexrock/fatura ↗ |
What it does
Build end-to-end features across frontend and backend systems
Files
Fatura — Principal Full-Stack Engineer
You are a principal engineer building Fatura. You combine deep Firebase expertise, product intuition for the Moroccan SMB market, and meticulous financial data integrity.
Read INSTRUCTIONS.md for project facts, conventions, and structure. This file governs how you think and make decisions.
---
Who You're Building For
Moroccan auto-entrepreneurs, freelancers, plumbers, designers, small retailers. They:
- Run their entire business from their phone
- Communicate with clients exclusively on WhatsApp
- Are not technical — they don't know what an API is or what TVA calculation means
- Need invoices that comply with DGI regulations but don't want to think about compliance
- Think in Dirhams, write in French, sometimes speak Darija
- Will abandon anything that takes more than 3 taps or feels confusing
Every decision you make should pass this test: would a plumber in Casablanca who just finished a job understand this in 5 seconds? If not, simplify.
---
How You Think
Before Touching Code
1. Read the codebase first. Open the relevant files listed in INSTRUCTIONS.md § "Files You Must Study." Understand the patterns before writing a single line. 2. Trace the full path. User action → WhatsApp message → webhook → NLP → Firestore → invoice creation → PDF → WhatsApp delivery → user sees PDF. If you can't trace it end-to-end, you don't understand the feature yet. 3. Identify what can break. Empty inputs? Concurrent sessions? NLP returning garbage? Firestore transaction failing? WhatsApp API rate-limited? PDF generation timing out? Meta sending duplicate webhooks? Plan for all of these.
While Writing Code
- Write the real implementation. No stubs, no placeholders, no TODOs without a clear
description of what needs doing and why.
- Follow existing patterns even if you'd do it differently. Consistency beats preference.
- Every async operation gets a try/catch. Every error gets logged with full context.
- Every user-facing message is in clear, simple French. No jargon, no technical terms.
After Writing Code
- Compile check:
cd functions && npm run buildmust succeed with zero errors. - Self-review: read your diff as if someone else wrote it. Would you approve this PR?
- Tell the project owner exactly what they need to do next: deploy commands, dashboard
configurations, testing steps. Be specific and concrete.
---
Decision Framework
Run every non-trivial decision through these five lenses:
1. Financial Data Integrity
This is a financial application. Getting money wrong is unacceptable.
- Is every monetary value an integer in centimes? No floats anywhere in the chain?
- Is the invoice counter incremented atomically in a transaction?
- Are TVA calculations using integer arithmetic with
Math.round()? - Could concurrent WhatsApp messages create duplicate invoice numbers?
- Does this produce invoices structurally identical to web-created ones?
- Does the TVA breakdown aggregate correctly across multiple line items?
2. User Experience
The user is not technical and has zero patience.
- What is the absolute minimum number of messages to complete this action?
- What smart defaults eliminate decisions? (20% TVA, today's date, 30-day payment terms,
quantity 1, price type HT)
- If something fails, does the user know what happened and what to do? In French?
- Is the WhatsApp message formatted cleanly? Not a wall of text?
- Can the user correct a mistake without starting over? (Modify flow)
3. Security & Trust
Users trust us with their business financial records.
- Is the webhook signature verified before any processing? (HMAC-SHA256)
- Can a user access another business's data through any code path?
- Are all API tokens in
functions.config(), never in source code? - Does every significant action leave an audit trail?
- Is rate limiting in place to prevent abuse?
4. Moroccan Business Context
This is not a generic invoicing app. It's built for Morocco specifically.
- Are TVA rates from the legal set (0, 7, 10, 14, 20)?
- Is the business's
tvaRegimerespected? (assujetti calculates TVA, non_assujetti and
exonere set it to 0 with appropriate mention on the invoice)
- Is ICE validation applied (15 digits, not all zeros)?
- Are phone numbers in the correct format for the context? (+212 for auth, no + for waId)
- Is currency always MAD with
fr-MAlocale formatting?
5. Reliability Under Real Conditions
Morocco has variable internet quality. Users send messages from construction sites.
- What happens if the Cloud Function cold-starts and takes 3 seconds?
(Webhook must still return 200 OK within 5s or Meta retries)
- What if Firestore write fails mid-transaction?
(User gets error message, session stays in current state, can retry)
- What if the Gemini API is down or slow?
(Fallback: ask user to rephrase in structured format)
- What if the PDF takes 30+ seconds to generate?
(Confirm invoice created, send PDF when ready or tell user to check the app)
- What if the user sends 5 messages while the bot is processing?
(Queue or reject with "please wait" — don't create 5 invoices)
---
Technical Judgment Calls
When to Use Transactions
Always use a Firestore transaction when:
- Creating an invoice (counter increment must be atomic with invoice creation)
- Recording a payment (payment array update + status change + client balance update)
- Creating a client/product inline during WhatsApp flow (ensure consistency)
Don't over-use transactions for simple reads or status-only updates.
When to Create vs. Reuse
The WhatsApp bot must reuse existing logic wherever possible:
- Reuse: TVA calculation logic, invoice data structure, PDF pipeline, activity logging
- Create new: Conversation state machine, NLP parsing, WhatsApp API messenger,
fuzzy matching, webhook handler
When creating the server-side invoice creator (functions/src/whatsapp/invoice-creator.ts), replicate the exact transaction pattern from src/lib/firestore.ts → createInvoice(), but using firebase-admin instead of the client SDK.
When to Ask the User vs. Assume
During a WhatsApp conversation:
- Assume: quantity=1, priceType=HT, tvaRate=business default (usually 20%), dueDate=today+paymentTermsDays
- Ask: client name (if not provided), price (if not provided), confirmation before generating
- Never assume: which client (if multiple matches), which product (if ambiguous)
- Never guess: if NLP confidence < 0.5, ask the user to rephrase
Error Message Philosophy
Error messages are part of the product. They must:
- Be in French, grammatically correct
- Tell the user what went wrong in non-technical terms
- Tell the user what to do next
- Never expose stack traces, function names, or Firestore paths
- Use a consistent, warm tone — not robotic, not overly casual
Good: "Désolé, je n'ai pas compris. Essayez: « Facture pour [client] [montant]dh »" Bad: "Error: NLP parse failed with confidence 0.3 on intent classification" Bad: "Une erreur s'est produite. Veuillez réessayer ultérieurement." (too vague)
---
Code Quality Standards
TypeScript
- All new types go in
src/types/index.ts. No type definitions scattered in other files. - Use the
CreateDTO<T>andUpdateDTO<T>utility types for write operations. - Minimize
any. If you must use it, add a comment explaining why. - Prefer union types over enums:
'facture' | 'avoir' | 'proforma' | 'devis'.
Cloud Functions
- Every function:
const fn = functions.region("europe-west1"); - Logging:
functions.logger.info/error/warn()with structured context objects. - Callable functions: always check
context.authfirst, throwHttpsErroron failure. - HTTPS functions: verify signatures/tokens before processing any data.
- Scheduled functions: use
Africa/Casablancatimezone for Morocco.
Firestore
- Use typed converters (see
convertersobject insrc/lib/firestore.ts). serverTimestamp()for created/updated fields. Exception: future dates likeexpiresAt.- Batch writes for bulk operations (max 500 per batch).
- Composite indexes: declare in
firestore.indexes.json, deploy withfirebase deploy --only firestore:indexes.
WhatsApp Messages
- Text messages: clear, concise French. One idea per message.
- Interactive buttons: max 3 per message (Meta limitation). Labels ≤ 20 characters.
- Interactive lists: max 10 items per section (Meta limitation). Use for disambiguation.
- Document messages: filename = invoice number (e.g.,
F-2026-0042.pdf), caption = summary. - Emojis: sparingly, functionally. ✅ for success, ⏳ for loading, ❌ for cancel. Not decorative.
Testing
- Unit tests for pure logic: TVA math, price parsing, string similarity, phone normalization.
- Integration tests for state machine transitions with mocked Firestore and NLP.
- Mock external APIs (Gemini, WhatsApp Cloud API) — never call real APIs in tests.
- Test edge cases: empty input, malformed webhook, expired sessions, concurrent requests.
---
Anti-Patterns — Hard Stops
If you catch yourself doing any of these, stop and rethink:
- Using floats for money instead of centimes integers
- Creating invoices outside a Firestore transaction
- Hardcoding TVA rates instead of using the
TvaRatetype - Skipping webhook signature verification on incoming WhatsApp messages
- Storing API keys or tokens anywhere in source code
- Using
console.loginstead offunctions.loggerin Cloud Functions - Importing
firebase/firestore(client SDK) in Cloud Functions instead offirebase-admin - Guessing at NLP output when confidence is below 0.5
- Sending technical error details to users in WhatsApp messages
- Creating empty placeholder files or TODO stubs
- Writing WhatsApp messages in English (users are French-speaking)
- Ignoring the existing code patterns in favor of "better" approaches
---
Communication Style
- Direct and concise. Lead with the answer, then explain.
- Show, don't tell. A code snippet beats a paragraph of explanation.
- When explaining trade-offs: "Option A gives X but costs Y. Option B gives Z but costs W.
I recommend A because..."
- Never hedge excessively. Confident when confident, honest when uncertain.
- When the project owner needs to act: exact commands, exact URLs, exact field values.
Not "deploy the functions" but firebase deploy --only functions.
{
"projects": {
"default": "fatura-saas-maroc"
},
"targets": {},
"etags": {}
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.*
*.docx
# Firebase Functions
functions/lib/
functions/node_modules/
# Compiled Types
src/types/*.js
src/types/*.js.map
Actions Requises (WhatsApp Bot via Twilio)
Puisque le compte Meta Business est bloqué et nécessite une vérification d'entreprise impossible pour le moment, nous avons migré vers Twilio WhatsApp Sandbox. Cela vous permet d'utiliser le bot immédiatement sans attendre de validation.
---
Étape 1 : Récupération des clés Twilio
1. Créez un compte sur Twilio.com (ou connectez-vous). 2. Dans votre Console Twilio (Dashboard principal), récupérez :
- Account SID
- Auth Token
3. Allez dans Messaging → Try it out → Send a WhatsApp message. 4. Suivez les instructions pour activer la Sandbox :
- Envoyez le code (ex:
join context-xyz) depuis votre téléphone au numéro Twilio indiqué (ex:+1 415 523 8886). - Notez ce numéro de téléphone Twilio (celui de la sandbox).
Étape 2 : Configuration du fichier .env
Allez dans votre dossier functions/ et mettez à jour (ou créez) le fichier .env avec vos nouveaux identifiants :
TWILIO_ACCOUNT_SID="VOTRE_ACCOUNT_SID"
TWILIO_AUTH_TOKEN="VOTRE_AUTH_TOKEN"
TWILIO_PHONE_NUMBER="whatsapp:+14155238886" # Le numéro de la Sandbox Twilio
GEMINI_API_KEY="VOTRE_CLE_API_GEMINI"Étape 3 : Déploiement du nouveau code
Nous avons refait le code pour Twilio. Déployez-le maintenant :
cd functions
npm run build
firebase deploy --only functionsÉtape 4 : Configuration du Webhook sur Twilio
C'est l'étape CRUCIALE pour que Twilio envoie les messages à votre code Firebase.
1. Dans la console Twilio, allez dans Messaging → Settings → WhatsApp Sandbox Settings. 2. Dans le champ "WHEN A MESSAGE COMES IN", collez votre URL Firebase : https://europe-west1-fatura-saas-maroc.cloudfunctions.net/whatsappWebhook 3. Vérifiez que la méthode à côté est bien HTTP POST. 4. Cliquez sur Save.
Étape 5 : Activation dans Fatura
1. Allez sur votre application Fatura (la version locale ou déployée). 2. Allez dans Paramètres (Settings) → WhatsApp. 3. Assurez-vous que votre numéro de téléphone est bien renseigné et cliquez sur Activer.
Étape 6 : Tests !
Envoyez un message depuis votre téléphone au numéro de la Sandbox Twilio :
1. Test AIDE : Envoyez "aide". 2. Test Facture : Envoyez "Facture pour Ahmed, consulting 5000dh". 3. Validation : Répondez avec les chiffres pour choisir le client/produit si demandé, puis cliquez sur "Générer" (qui sera un message texte "1" ou "Générer" car la sandbox ne supporte pas bien les boutons riches sans templates pré-approuvés).
--- Note sur les Boutons : Dans la Sandbox Twilio, nous utilisons des réponses numérotées (ex: [1] Confirmer, [2] Modifier) car les vrais boutons WhatsApp nécessitent une approbation de template par Meta, ce qui prend du temps.
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
{
"functions": {
"source": "functions",
"runtime": "nodejs20",
"codebase": "default"
},
"firestore": {
"database": "(default)",
"location": "eur3",
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
},
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
},
"auth": {
"providers": {
"anonymous": false,
"emailPassword": false
}
}
}
{
"indexes": [
{
"collectionGroup": "whatsappSessions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "businessId", "order": "ASCENDING" },
{ "fieldPath": "state", "order": "ASCENDING" },
{ "fieldPath": "expiresAt", "order": "ASCENDING" }
]
},
{
"collectionGroup": "whatsappSessions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "businessId", "order": "ASCENDING" },
{ "fieldPath": "waId", "order": "ASCENDING" },
{ "fieldPath": "state", "order": "ASCENDING" }
]
},
{
"collectionGroup": "whatsappSessions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "waId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /whatsappLinks/{waId} {
allow read, update, delete: if request.auth != null && request.auth.uid == resource.data.ownerId;
allow create: if request.auth != null && request.auth.uid == request.resource.data.ownerId;
}
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}{
"name": "fatura-functions",
"private": true,
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "20"
},
"main": "lib/functions/src/index.js",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"firebase-admin": "^12.0.0",
"firebase-functions": "^5.0.0",
"pdfkit": "^0.15.0",
"twilio": "^6.0.0"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/pdfkit": "^0.13.4",
"jest": "^30.3.0",
"ts-jest": "^29.4.9",
"typescript": "^5.4.0"
}
}
/**
* Fatura — Firebase Cloud Functions
*
* All money values are in centimes (integer). 1 MAD = 100 centimes.
* Region: europe-west1
*
* Functions:
* 1. onInvoiceCreated — Firestore trigger: auto-generate PDF + log
* 2. onInvoiceStatusChanged — Firestore trigger: handle status transitions
* 3. sendInvoiceReminder — callable: WhatsApp deep-link builder
* 4. generateTVAReport — callable: SIMPL-TVA quarter report
* 5. validateICE — callable: ICE format validator
* 6. scheduledOverdueCheck — scheduled daily 8am Africa/Casablanca
*/
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import PDFDocument from "pdfkit";
admin.initializeApp();
const db = admin.firestore();
const bucket = admin.storage().bucket();
const REGION = "europe-west1";
const fn = functions.region(REGION);
// =============================================================================
// HELPERS
// =============================================================================
/** Convert centimes integer to MAD display string */
function centimesToMAD(centimes: number): string {
const val = centimes / 100;
return val.toLocaleString("fr-MA", {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " MAD";
}
/** Format Firestore Timestamp or epoch to YYYY-MM-DD */
function formatDate(ts: admin.firestore.Timestamp | {_seconds: number}): string {
const d = ts instanceof admin.firestore.Timestamp
? ts.toDate()
: new Date((ts as any)._seconds * 1000);
return d.toISOString().slice(0, 10);
}
/** Log an activity entry */
export async function logActivity(
businessId: string,
userId: string,
action: string,
entityType: string,
entityId: string,
details: Record<string, unknown> = {}
): Promise<void> {
await db
.collection("businesses").doc(businessId)
.collection("activity").add({
businessId,
userId: userId || "system",
action,
entityType,
entityId,
details,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
}
// =============================================================================
// 1. onInvoiceCreated — Generate PDF on invoice creation
// =============================================================================
export const onInvoiceCreated = fn.firestore
.document("businesses/{businessId}/invoices/{invoiceId}")
.onCreate(async (snap, context) => {
const {businessId, invoiceId} = context.params;
const invoice = snap.data();
if (!invoice) return;
try {
// Fetch business data for PDF header
const businessDoc = await db.collection("businesses").doc(businessId).get();
const business = businessDoc.data();
if (!business) {
functions.logger.error("Business not found", {businessId});
return;
}
// Fetch client data
const clientDoc = await db
.collection("businesses").doc(businessId)
.collection("clients").doc(invoice.clientId)
.get();
const client = clientDoc.data();
// -----------------------------------------------------------------------
// Generate PDF with PDFKit
// -----------------------------------------------------------------------
const pdfBuffer = await generateInvoicePDF(invoice, business, client, invoiceId);
// Upload to Storage
const filePath = `invoices/${businessId}/${invoiceId}.pdf`;
const file = bucket.file(filePath);
await file.save(pdfBuffer, {
metadata: {
contentType: "application/pdf",
metadata: {
invoiceNumber: invoice.number || invoiceId,
businessId,
},
},
});
// Make file publicly accessible (or use signed URL)
await file.makePublic();
const pdfUrl = `https://storage.googleapis.com/${bucket.name}/${filePath}`;
// Update invoice with PDF URL
await snap.ref.update({pdfUrl, updatedAt: admin.firestore.FieldValue.serverTimestamp()});
// Log activity
await logActivity(
businessId,
invoice.createdBy || business.ownerId || "system",
"Facture créée",
"invoice",
invoiceId,
{number: invoice.number, totalTTC: invoice.totals?.totalTTC}
);
functions.logger.info("Invoice PDF generated", {businessId, invoiceId, filePath});
} catch (err) {
functions.logger.error("onInvoiceCreated failed", {businessId, invoiceId, error: err});
}
});
/**
* Generate a professional invoice PDF using PDFKit.
*/
async function generateInvoicePDF(
invoice: FirebaseFirestore.DocumentData,
business: FirebaseFirestore.DocumentData,
client: FirebaseFirestore.DocumentData | undefined,
invoiceId: string
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({size: "A4", margin: 50, bufferPages: true});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const brandColor = business.brandColor || "#1B4965";
// --- Header bar ---
doc.rect(0, 0, 595.28, 8).fill(brandColor);
// --- Business Info (top-left) ---
doc.fontSize(14).fillColor(brandColor).text(business.legalName || "Entreprise", 50, 30);
doc.fontSize(8).fillColor("#666666");
if (business.tradeName) doc.text(business.tradeName);
if (business.address) {
doc.text(`${business.address.street || ""}, ${business.address.postalCode || ""} ${business.address.city || ""}`);
}
if (business.phone) doc.text(`Tél: ${business.phone}`);
if (business.email) doc.text(`Email: ${business.email}`);
doc.moveDown(0.3);
doc.fontSize(7).fillColor("#999999");
if (business.ice) doc.text(`ICE: ${business.ice}`);
if (business.identifiantFiscal) doc.text(`IF: ${business.identifiantFiscal}`);
if (business.registreCommerce) doc.text(`RC: ${business.registreCommerce}`);
// --- Invoice Title (top-right) ---
const typeLabel = getInvoiceTypeLabel(invoice.type);
doc.fontSize(20).fillColor(brandColor).text(typeLabel, 350, 30, {width: 200, align: "right"});
doc.fontSize(10).fillColor("#333333").text(invoice.number || invoiceId, 350, 55, {width: 200, align: "right"});
// --- Date block ---
doc.fontSize(9).fillColor("#666666");
doc.text(`Date d'émission: ${formatDate(invoice.issueDate)}`, 350, 75, {width: 200, align: "right"});
if (invoice.dueDate) {
doc.text(`Date d'échéance: ${formatDate(invoice.dueDate)}`, 350, 88, {width: 200, align: "right"});
}
// --- Client block ---
const clientY = 140;
doc.roundedRect(350, clientY, 200, 80, 4).fill("#f8fafc").stroke();
doc.fontSize(7).fillColor("#999999").text("FACTURER À", 360, clientY + 8);
doc.fontSize(10).fillColor("#333333").text(client?.name || "Client", 360, clientY + 22, {width: 180});
doc.fontSize(8).fillColor("#666666");
if (client?.address) {
doc.text(`${client.address.street || ""}, ${client.address.postalCode || ""} ${client.address.city || ""}`, 360, clientY + 38, {width: 180});
}
if (client?.ice) doc.text(`ICE: ${client.ice}`, 360, clientY + 55, {width: 180});
if (client?.phone) doc.text(`Tél: ${client.phone}`, 360, clientY + 65, {width: 180});
// --- Line items table ---
const tableTop = 250;
const cols = {desc: 50, qty: 310, unit: 360, tva: 420, total: 480};
// Table header
doc.rect(50, tableTop, 495.28, 22).fill(brandColor);
doc.fontSize(8).fillColor("#ffffff");
doc.text("Description", cols.desc + 8, tableTop + 7);
doc.text("Qté", cols.qty, tableTop + 7, {width: 40, align: "center"});
doc.text("P.U. HT", cols.unit, tableTop + 7, {width: 50, align: "right"});
doc.text("TVA", cols.tva, tableTop + 7, {width: 40, align: "center"});
doc.text("Total HT", cols.total, tableTop + 7, {width: 60, align: "right"});
// Table rows
let y = tableTop + 28;
const lines: any[] = invoice.lines || [];
lines.forEach((line: any, i: number) => {
const bg = i % 2 === 0 ? "#ffffff" : "#f8fafc";
doc.rect(50, y - 4, 495.28, 20).fill(bg);
doc.fontSize(8).fillColor("#333333");
doc.text(line.description || "", cols.desc + 8, y, {width: 250});
doc.text(String(line.quantity || 0), cols.qty, y, {width: 40, align: "center"});
doc.text(centimesToMAD(line.unitPrice || 0), cols.unit, y, {width: 50, align: "right"});
doc.text(`${line.tvaRate || 0}%`, cols.tva, y, {width: 40, align: "center"});
doc.text(centimesToMAD(line.totalHT || 0), cols.total, y, {width: 60, align: "right"});
y += 20;
});
// Divider
y += 10;
doc.moveTo(350, y).lineTo(545, y).strokeColor("#e2e8f0").stroke();
y += 8;
// --- Totals ---
const totals = invoice.totals || {};
doc.fontSize(9).fillColor("#666666");
doc.text("Total HT", 360, y, {width: 100});
doc.text(centimesToMAD(totals.totalHT || 0), 460, y, {width: 80, align: "right"});
y += 16;
// TVA breakdown
const tvaBreakdown: any[] = totals.tvaBreakdown || [];
tvaBreakdown.forEach((t: any) => {
doc.text(`TVA ${t.rate}%`, 360, y, {width: 100});
doc.text(centimesToMAD(t.amount || 0), 460, y, {width: 80, align: "right"});
y += 14;
});
y += 4;
doc.rect(350, y, 195, 24).fill(brandColor);
doc.fontSize(11).fillColor("#ffffff");
doc.text("Total TTC", 360, y + 6, {width: 100});
doc.text(centimesToMAD(totals.totalTTC || 0), 460, y + 6, {width: 80, align: "right"});
// --- Notes ---
if (invoice.notes) {
const notesY = Math.max(y + 50, 550);
doc.fontSize(8).fillColor("#999999").text("Notes:", 50, notesY);
doc.fontSize(8).fillColor("#666666").text(invoice.notes, 50, notesY + 12, {width: 300});
}
// --- Bank details (footer) ---
if (business.bankDetails) {
const bk = business.bankDetails;
const bankY = 720;
doc.fontSize(7).fillColor("#999999");
doc.text("Coordonnées Bancaires", 50, bankY);
doc.fontSize(7).fillColor("#666666");
if (bk.bankName) doc.text(`Banque: ${bk.bankName}`, 50, bankY + 10);
if (bk.rib) doc.text(`RIB: ${bk.rib}`, 50, bankY + 20);
if (bk.iban) doc.text(`IBAN: ${bk.iban}`, 250, bankY + 10);
if (bk.swift) doc.text(`SWIFT: ${bk.swift}`, 250, bankY + 20);
}
// --- Footer bar ---
doc.rect(0, 841.89 - 8, 595.28, 8).fill(brandColor);
doc.end();
});
}
function getInvoiceTypeLabel(type: string): string {
const map: Record<string, string> = {
facture: "FACTURE",
avoir: "AVOIR",
proforma: "FACTURE PROFORMA",
devis: "DEVIS",
};
return map[type] || "FACTURE";
}
// =============================================================================
// 2. onInvoiceStatusChanged — Handle status transitions
// =============================================================================
export const onInvoiceStatusChanged = fn.firestore
.document("businesses/{businessId}/invoices/{invoiceId}")
.onUpdate(async (change, context) => {
const {businessId, invoiceId} = context.params;
const before = change.before.data();
const after = change.after.data();
if (!before || !after) return;
if (before.status === after.status) return; // No status change
const newStatus: string = after.status;
const oldStatus: string = before.status;
functions.logger.info("Invoice status changed", {
businessId, invoiceId,
from: oldStatus, to: newStatus,
});
try {
// --- OVERDUE ---
if (newStatus === "overdue") {
await logActivity(
businessId, "system", "Facture en retard",
"invoice", invoiceId,
{number: after.number, dueDate: formatDate(after.dueDate)}
);
}
// --- PAID ---
if (newStatus === "paid") {
// Update client's totalPaid denormalized field
const clientId: string = after.clientId;
if (clientId) {
const totalTTC: number = after.totals?.totalTTC || 0;
const clientRef = db
.collection("businesses").doc(businessId)
.collection("clients").doc(clientId);
await db.runTransaction(async (t) => {
const clientDoc = await t.get(clientRef);
if (!clientDoc.exists) return;
const clientData = clientDoc.data()!;
const currentPaid: number = clientData.totalPaid || 0;
const currentInvoiced: number = clientData.totalInvoiced || 0;
const newPaid = currentPaid + totalTTC;
t.update(clientRef, {
totalPaid: newPaid,
balance: currentInvoiced - newPaid,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
});
}
// Update paidAt timestamp
await change.after.ref.update({
paidAt: admin.firestore.FieldValue.serverTimestamp(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
await logActivity(
businessId, "system", "Facture payée",
"invoice", invoiceId,
{number: after.number, totalTTC: after.totals?.totalTTC}
);
}
// --- CANCELLED ---
if (newStatus === "cancelled") {
await change.after.ref.update({
cancelledAt: admin.firestore.FieldValue.serverTimestamp(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
await logActivity(
businessId, "system", "Facture annulée",
"invoice", invoiceId,
{
number: after.number,
reason: after.cancellationReason || "Non spécifié",
previousStatus: oldStatus,
}
);
}
// --- SENT ---
if (newStatus === "sent" && oldStatus === "draft") {
await change.after.ref.update({
sentAt: admin.firestore.FieldValue.serverTimestamp(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
// Update client's totalInvoiced
const clientId: string = after.clientId;
if (clientId) {
const totalTTC: number = after.totals?.totalTTC || 0;
const clientRef = db
.collection("businesses").doc(businessId)
.collection("clients").doc(clientId);
await db.runTransaction(async (t) => {
const clientDoc = await t.get(clientRef);
if (!clientDoc.exists) return;
const clientData = clientDoc.data()!;
const currentInvoiced: number = clientData.totalInvoiced || 0;
const currentPaid: number = clientData.totalPaid || 0;
const newInvoiced = currentInvoiced + totalTTC;
t.update(clientRef, {
totalInvoiced: newInvoiced,
balance: newInvoiced - currentPaid,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
});
}
await logActivity(
businessId, "system", "Facture envoyée",
"invoice", invoiceId,
{number: after.number}
);
}
} catch (err) {
functions.logger.error("onInvoiceStatusChanged failed", {
businessId, invoiceId, error: err,
});
}
});
// =============================================================================
// 3. sendInvoiceReminder — WhatsApp deep link builder
// =============================================================================
export const sendInvoiceReminder = fn.https.onCall(async (data, context) => {
// Auth check
if (!context.auth) {
throw new functions.https.HttpsError("unauthenticated", "Authentification requise.");
}
const {businessId, invoiceId} = data;
if (!businessId || typeof businessId !== "string") {
throw new functions.https.HttpsError("invalid-argument", "businessId requis.");
}
if (!invoiceId || typeof invoiceId !== "string") {
throw new functions.https.HttpsError("invalid-argument", "invoiceId requis.");
}
// Fetch invoice
const invoiceDoc = await db
.collection("businesses").doc(businessId)
.collection("invoices").doc(invoiceId)
.get();
if (!invoiceDoc.exists) {
throw new functions.https.HttpsError("not-found", "Facture introuvable.");
}
const invoice = invoiceDoc.data()!;
// Fetch client
const clientDoc = await db
.collection("businesses").doc(businessId)
.collection("clients").doc(invoice.clientId)
.get();
const client = clientDoc.data();
// Fetch business
const businessDoc = await db.collection("businesses").doc(businessId).get();
const business = businessDoc.data();
const clientPhone = client?.phone?.replace(/\s+/g, "").replace(/^0/, "+212") || "";
const clientName = client?.name || "Client";
const invoiceNumber = invoice.number || invoiceId;
const amount = centimesToMAD(invoice.totals?.totalTTC || 0);
const businessName = business?.legalName || "Votre fournisseur";
const message = `Bonjour ${clientName}, votre facture ${invoiceNumber} d'un montant de ${amount} est en attente de règlement. Merci de procéder au paiement dans les meilleurs délais. — ${businessName}`;
const whatsappUrl = `https://wa.me/${clientPhone.replace("+", "")}?text=${encodeURIComponent(message)}`;
// Log the reminder
await logActivity(
businessId,
context.auth.uid,
"Rappel envoyé",
"invoice",
invoiceId,
{number: invoiceNumber, via: "whatsapp", clientName}
);
return {
url: whatsappUrl,
message,
clientPhone,
clientName,
};
});
// =============================================================================
// 4. generateTVAReport — SIMPL-TVA quarter report
// =============================================================================
interface TVALine {
rate: number;
baseHT: number; // centimes
tvaAmount: number; // centimes
invoiceCount: number;
}
export const generateTVAReport = fn.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError("unauthenticated", "Authentification requise.");
}
const {businessId, quarter, year} = data;
if (!businessId || typeof businessId !== "string") {
throw new functions.https.HttpsError("invalid-argument", "businessId requis.");
}
if (!quarter || !["Q1", "Q2", "Q3", "Q4"].includes(quarter)) {
throw new functions.https.HttpsError("invalid-argument", "quarter invalide (Q1/Q2/Q3/Q4).");
}
if (!year || typeof year !== "number" || year < 2020 || year > 2100) {
throw new functions.https.HttpsError("invalid-argument", "year invalide.");
}
// Determine date range for the quarter
const quarterStartMonth: Record<string, number> = {
Q1: 0, Q2: 3, Q3: 6, Q4: 9,
};
const startMonth = quarterStartMonth[quarter];
const startDate = new Date(year, startMonth, 1);
const endDate = new Date(year, startMonth + 3, 0, 23, 59, 59); // last day of quarter
const startTs = admin.firestore.Timestamp.fromDate(startDate);
const endTs = admin.firestore.Timestamp.fromDate(endDate);
// Query invoices in period (only sent, paid, partially_paid — not drafts or cancelled)
const validStatuses = ["sent", "validated", "paid", "partially_paid", "overdue"];
const invoicesSnap = await db
.collection("businesses").doc(businessId)
.collection("invoices")
.where("issueDate", ">=", startTs)
.where("issueDate", "<=", endTs)
.get();
const tvaMap = new Map<number, TVALine>();
let totalHT = 0;
let totalTVA = 0;
let totalTTC = 0;
let invoiceCount = 0;
invoicesSnap.docs.forEach((doc) => {
const inv = doc.data();
// Skip drafts and cancelled
if (!validStatuses.includes(inv.status)) return;
invoiceCount++;
const totals = inv.totals || {};
totalHT += totals.totalHT || 0;
totalTVA += totals.totalTVA || 0;
totalTTC += totals.totalTTC || 0;
// Aggregate by TVA rate
const breakdown: any[] = totals.tvaBreakdown || [];
breakdown.forEach((entry: any) => {
const rate = entry.rate ?? 0;
const existing = tvaMap.get(rate) || {rate, baseHT: 0, tvaAmount: 0, invoiceCount: 0};
existing.baseHT += entry.base || 0;
existing.tvaAmount += entry.amount || 0;
existing.invoiceCount += 1;
tvaMap.set(rate, existing);
});
});
// Build sorted breakdown
const tvaBreakdown = Array.from(tvaMap.values()).sort((a, b) => a.rate - b.rate);
return {
period: {quarter, year},
dateRange: {
start: startDate.toISOString().slice(0, 10),
end: endDate.toISOString().slice(0, 10),
},
invoiceCount,
totalHT,
totalTVA,
totalTTC,
// Formatted for humans
totalHT_MAD: centimesToMAD(totalHT),
totalTVA_MAD: centimesToMAD(totalTVA),
totalTTC_MAD: centimesToMAD(totalTTC),
tvaBreakdown: tvaBreakdown.map((t) => ({
rate: t.rate,
baseHT: t.baseHT,
tvaAmount: t.tvaAmount,
invoiceCount: t.invoiceCount,
baseHT_MAD: centimesToMAD(t.baseHT),
tvaAmount_MAD: centimesToMAD(t.tvaAmount),
})),
// SIMPL-TVA reference
simplTVA: {
regime: "Mensuel ou Trimestriel",
reference: `TVA-${quarter}-${year}`,
note: "Ce rapport est généré à titre indicatif. Veuillez vérifier les montants avant soumission au portail SIMPL.",
},
};
});
// =============================================================================
// 5. validateICE — ICE format validator
// =============================================================================
export const validateICE = fn.https.onCall(async (data) => {
const {ice} = data;
if (!ice || typeof ice !== "string") {
return {valid: false, error: "ICE requis (chaîne de caractères)."};
}
const trimmed = ice.trim();
// Must be exactly 15 digits
if (!/^\d{15}$/.test(trimmed)) {
if (trimmed.length !== 15) {
return {
valid: false,
error: `L'ICE doit contenir exactement 15 chiffres. Longueur actuelle: ${trimmed.length}.`,
};
}
return {
valid: false,
error: "L'ICE ne doit contenir que des chiffres (0-9).",
};
}
// Basic checksum/format heuristics (Moroccan ICE structure)
// First 9 digits = company number, next 4 = establishment, last 2 = control
const companyPart = trimmed.slice(0, 9);
const establishmentPart = trimmed.slice(9, 13);
const controlPart = trimmed.slice(13, 15);
// Check that company part is not all zeros
if (/^0+$/.test(companyPart)) {
return {valid: false, error: "Numéro d'entreprise invalide (tout zéros)."};
}
return {
valid: true,
structure: {
companyNumber: companyPart,
establishmentNumber: establishmentPart,
controlDigits: controlPart,
},
note: "Format valide. La vérification auprès du registre DGI sera disponible prochainement.",
};
});
// =============================================================================
// 6. scheduledOverdueCheck — Daily 8am Morocco time
// =============================================================================
export const scheduledOverdueCheck = fn.pubsub
.schedule("0 8 * * *") // 8:00 AM every day
.timeZone("Africa/Casablanca")
.onRun(async () => {
const now = admin.firestore.Timestamp.now();
functions.logger.info("Running scheduled overdue check", {timestamp: now.toDate().toISOString()});
try {
// Get all businesses
const businessesSnap = await db.collection("businesses").get();
let totalUpdated = 0;
for (const businessDoc of businessesSnap.docs) {
const businessId = businessDoc.id;
// Query invoices that are "sent" and past due date
const overdueSnap = await db
.collection("businesses").doc(businessId)
.collection("invoices")
.where("status", "==", "sent")
.where("dueDate", "<", now)
.get();
if (overdueSnap.empty) continue;
// Batch update
const batch = db.batch();
overdueSnap.docs.forEach((invoiceDoc) => {
batch.update(invoiceDoc.ref, {
status: "overdue",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
});
await batch.commit();
totalUpdated += overdueSnap.size;
functions.logger.info(`Marked ${overdueSnap.size} invoices as overdue`, {businessId});
}
functions.logger.info("Overdue check complete", {totalUpdated});
} catch (err) {
functions.logger.error("scheduledOverdueCheck failed", {error: err});
}
});
// =============================================================================
// 7. cleanupWhatsAppSessions — Every 15 minutes
// =============================================================================
export const cleanupWhatsAppSessions = fn.pubsub
.schedule("*/15 * * * *") // Every 15 minutes
.timeZone("Africa/Casablanca")
.onRun(async () => {
const now = admin.firestore.Timestamp.now();
functions.logger.info("Running WhatsApp session cleanup", {
timestamp: now.toDate().toISOString(),
});
try {
const businessesSnap = await db.collection("businesses").get();
let totalCleaned = 0;
for (const businessDoc of businessesSnap.docs) {
const businessId = businessDoc.id;
// Find sessions where expiresAt < now and state is not already 'delivered'
const expiredSnap = await db
.collection("businesses")
.doc(businessId)
.collection("whatsappSessions")
.where("expiresAt", "<", now)
.limit(100) // Batch limit per business
.get();
if (expiredSnap.empty) continue;
const batch = db.batch();
let count = 0;
for (const sessionDoc of expiredSnap.docs) {
const sessionData = sessionDoc.data();
// Only clean sessions that are not in a terminal state
if (sessionData.state !== 'delivered') {
batch.update(sessionDoc.ref, {
state: "idle",
intentData: {},
resolvedData: {},
pendingField: null,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
count++;
}
}
if (count > 0) {
await batch.commit();
totalCleaned += count;
functions.logger.info(`Cleaned ${count} expired sessions`, { businessId });
}
}
functions.logger.info("WhatsApp session cleanup complete", { totalCleaned });
} catch (err) {
functions.logger.error("cleanupWhatsAppSessions failed", { error: err });
}
});
// =============================================================================
// 8. WhatsApp Webhook
// =============================================================================
export { whatsappWebhook } from "./whatsapp/webhook";
// =============================================================================
// 9. WhatsApp Link / Unlink — Callable Functions
// =============================================================================
/**
* linkWhatsApp — Links a WhatsApp phone number to the current business.
* Creates a whatsappLinks document and stores preferences on the business.
*/
export const linkWhatsApp = fn.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError("unauthenticated", "Vous devez être connecté.");
}
const { phoneNumber, preferences } = data as {
phoneNumber: string;
preferences?: {
defaultTvaRate?: number;
autoConfirm?: boolean;
language?: "fr" | "ar";
notifyOnGeneration?: boolean;
};
};
if (!phoneNumber || typeof phoneNumber !== "string") {
throw new functions.https.HttpsError("invalid-argument", "Numéro de téléphone requis.");
}
// Normalize: strip all non-digits, ensure starts with country code
const waId = phoneNumber.replace(/\D/g, "");
if (waId.length < 10 || waId.length > 15) {
throw new functions.https.HttpsError(
"invalid-argument",
"Numéro de téléphone invalide. Utilisez le format international (ex: 212600000000)."
);
}
// Get the user's business
const uid = context.auth.uid;
const businessSnap = await db.collection("businesses")
.where("ownerId", "==", uid)
.limit(1)
.get();
if (businessSnap.empty) {
throw new functions.https.HttpsError("not-found", "Entreprise non trouvée.");
}
const businessDoc = businessSnap.docs[0];
const businessId = businessDoc.id;
// Check if this phone is already linked to another business
const existingLink = await db.collection("whatsappLinks").doc(waId).get();
if (existingLink.exists) {
const linkData = existingLink.data();
if (linkData?.isActive && linkData?.businessId !== businessId) {
throw new functions.https.HttpsError(
"already-exists",
"Ce numéro est déjà lié à une autre entreprise."
);
}
}
const now = admin.firestore.FieldValue.serverTimestamp();
// Create or update the whatsappLinks document
await db.collection("whatsappLinks").doc(waId).set({
waId,
businessId,
ownerId: uid,
isActive: true,
linkedAt: now,
lastMessageAt: now,
});
await logActivity(businessId, uid, "WhatsApp: compte lié", "whatsappLinks", waId, { waId, preferences });
// Store preferences on the business document
const whatsappPrefs = {
defaultTvaRate: preferences?.defaultTvaRate ?? 20,
autoConfirm: preferences?.autoConfirm ?? false,
language: preferences?.language ?? "fr",
notifyOnGeneration: preferences?.notifyOnGeneration ?? true,
};
await businessDoc.ref.update({
whatsappLinkedPhone: waId,
whatsappPreferences: whatsappPrefs,
updatedAt: now,
});
// Send welcome message (fire-and-forget)
try {
const { sendWelcomeMessage } = await import("./whatsapp/messenger");
await sendWelcomeMessage(waId);
} catch (e) {
functions.logger.warn("Failed to send WhatsApp welcome message", { error: e });
}
functions.logger.info("WhatsApp linked successfully", { waId, businessId });
return {
success: true,
waId,
message: "WhatsApp lié avec succès !",
};
});
/**
* unlinkWhatsApp — Unlinks a WhatsApp phone number from the current business.
*/
export const unlinkWhatsApp = fn.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError("unauthenticated", "Vous devez être connecté.");
}
const uid = context.auth.uid;
// Get the user's business
const businessSnap = await db.collection("businesses")
.where("ownerId", "==", uid)
.limit(1)
.get();
if (businessSnap.empty) {
throw new functions.https.HttpsError("not-found", "Entreprise non trouvée.");
}
const businessDoc = businessSnap.docs[0];
const businessData = businessDoc.data();
const waId = businessData?.whatsappLinkedPhone;
if (!waId) {
throw new functions.https.HttpsError("not-found", "Aucun numéro WhatsApp lié.");
}
// Deactivate the whatsappLinks document
const linkRef = db.collection("whatsappLinks").doc(waId);
const linkDoc = await linkRef.get();
if (linkDoc.exists) {
await linkRef.update({
isActive: false,
deactivatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
}
// Remove WhatsApp fields from business
await businessDoc.ref.update({
whatsappLinkedPhone: admin.firestore.FieldValue.delete(),
whatsappPreferences: admin.firestore.FieldValue.delete(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
functions.logger.info("WhatsApp unlinked", { waId, businessId: businessDoc.id });
return { success: true, message: "WhatsApp délié avec succès." };
});
// =============================================================================
/**
* getWhatsAppStats — Retrieves usage statistics for the WhatsApp integration.
*/
export const getWhatsAppStats = fn.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError("unauthenticated", "Vous devez être connecté.");
}
const { businessId } = data as { businessId: string };
if (!businessId) {
throw new functions.https.HttpsError("invalid-argument", "businessId requis.");
}
// Verify ownership
const businessDoc = await db.collection("businesses").doc(businessId).get();
if (!businessDoc.exists || businessDoc.data()?.ownerId !== context.auth.uid) {
throw new functions.https.HttpsError("permission-denied", "Accès refusé.");
}
const now = Date.now();
const thirtyDaysAgo = admin.firestore.Timestamp.fromMillis(now - 30 * 24 * 60 * 60 * 1000);
// 1. Get recent activity for the last 30 days
const activitySnap = await db.collection(`businesses/${businessId}/activity`)
.where("createdAt", ">=", thirtyDaysAgo)
.orderBy("createdAt", "desc")
.get();
let totalSessions = 0;
let invoicesCreated = 0;
let errors = 0;
let pdfSent = 0;
const recentActivity: any[] = [];
activitySnap.forEach(doc => {
const act = doc.data();
if (typeof act.action === 'string' && act.action.startsWith('WhatsApp:')) {
if (recentActivity.length < 10) {
recentActivity.push({ id: doc.id, ...act });
}
switch (act.action) {
case 'WhatsApp: session démarrée':
totalSessions++;
break;
case 'WhatsApp: facture créée':
invoicesCreated++;
break;
case 'WhatsApp: erreur':
errors++;
break;
case 'WhatsApp: PDF envoyé':
pdfSent++;
break;
}
}
});
// 2. Check active link
const linkQuery = await db.collection("whatsappLinks")
.where("businessId", "==", businessId)
.where("isActive", "==", true)
.limit(1)
.get();
let activeLink = null;
if (!linkQuery.empty) {
const linkDoc = linkQuery.docs[0].data();
activeLink = {
waId: linkDoc.waId,
linkedAt: linkDoc.linkedAt,
lastMessageAt: linkDoc.lastMessageAt
};
}
return {
success: true,
stats: {
totalSessions,
invoicesCreated,
errors,
pdfSent,
successRate: totalSessions > 0 ? Math.round((invoicesCreated / totalSessions) * 100) : 0,
activeLink,
recentActivity
}
};
});
import { parseMoroccanPrice, parseInvoiceIntent } from '../nlp';
import { jaroWinklerSimilarity } from '../matcher';
// Simple mock for GoogleGenerativeAI
jest.mock('@google/generative-ai', () => {
return {
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
getGenerativeModel: jest.fn().mockImplementation(() => ({
generateContent: jest.fn().mockResolvedValue({
response: {
text: () => JSON.stringify({
intent: 'create_invoice',
confidence: 0.9,
entities: {
clientName: 'Ahmed',
productLabel: 'logo design',
quantity: 1,
unitPrice: 8500,
currency: 'MAD',
tvaOverride: null,
priceType: 'HT',
dueDate: null,
notes: null
}
})
}
})
}))
})),
SchemaType: { OBJECT: 'OBJECT', STRING: 'STRING', NUMBER: 'NUMBER' },
Schema: {}
};
});
// Mock config
jest.mock('../config', () => ({
whatsappConfig: {
geminiApiKey: 'MOCK_API_KEY'
}
}));
// Mock logger
jest.mock('firebase-functions/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn()
}));
describe('NLP & Matcher Helpers', () => {
describe('parseMoroccanPrice', () => {
it('parses basic integer strings', () => {
expect(parseMoroccanPrice('500')).toBe(50000);
expect(parseMoroccanPrice('8500')).toBe(850000);
});
it('parses strings with suffixes (dh, MAD)', () => {
expect(parseMoroccanPrice('8500dh')).toBe(850000);
expect(parseMoroccanPrice('15000 MAD')).toBe(1500000);
expect(parseMoroccanPrice('8500,50 dh')).toBe(850050);
});
it('handles Moroccan dot-for-thousands and comma-for-decimals format', () => {
expect(parseMoroccanPrice('8.500,00')).toBe(850000);
expect(parseMoroccanPrice('15.000,50')).toBe(1500050);
});
it('handles simple decimals', () => {
expect(parseMoroccanPrice('8500.50')).toBe(850050);
expect(parseMoroccanPrice('8500,50')).toBe(850050);
});
it('handles spaced numbers', () => {
expect(parseMoroccanPrice('8 500 MAD')).toBe(850000);
});
});
describe('jaroWinklerSimilarity', () => {
it('returns 1.0 for exact matches', () => {
expect(jaroWinklerSimilarity('Ahmed', 'Ahmed')).toBe(1.0);
});
it('returns high score (> 0.8) for minor typos', () => {
const score = jaroWinklerSimilarity('Ahmed', 'Ahmad');
expect(score).toBeGreaterThan(0.8);
});
it('returns low score (< 0.5) for completely different words', () => {
const score = jaroWinklerSimilarity('Ahmed', 'Karim');
expect(score).toBeLessThan(0.5);
});
it('handles case and diacritics', () => {
const score1 = jaroWinklerSimilarity('Océane', 'oceane');
expect(score1).toBe(1.0);
});
});
describe('parseInvoiceIntent (Mocked Gemini)', () => {
it('extracts intent correctly from message', async () => {
const message = "Facture pour Ahmed, logo design 8500dh";
const result = await parseInvoiceIntent(message);
expect(result.intent).toBe('create_invoice');
expect(result.confidence).toBe(0.9);
expect(result.entities.clientName).toBe('Ahmed');
expect(result.entities.productLabel).toBe('logo design');
expect(result.entities.unitPrice).toBe(850000); // Converted by our wrapper
});
});
});
import * as functions from 'firebase-functions';
// We use getters so that the config is read at runtime instead of module load time.
// This is important for Firebase Functions to pick up config changes properly,
// and it provides placeholder fallbacks for when the config isn't fully set yet.
export const whatsappConfig = {
get twilioAccountSid() { return process.env.TWILIO_ACCOUNT_SID || 'PLACEHOLDER_SID'; },
get twilioAuthToken() { return process.env.TWILIO_AUTH_TOKEN || 'PLACEHOLDER_TOKEN'; },
get twilioPhoneNumber() { return process.env.TWILIO_PHONE_NUMBER || 'whatsapp:+14155238886'; }, // Sandbox number
get geminiApiKey() { return process.env.GEMINI_API_KEY || 'PLACEHOLDER_GEMINI_KEY'; }
};
import * as admin from 'firebase-admin';
import * as logger from 'firebase-functions/logger';
import { WebhookMessage } from './types';
import { WhatsAppSession, InvoiceLine, NlpIntent } from '../../../src/types';
import { parseInvoiceIntent, parseMoroccanPrice } from './nlp';
import { findClientByName, findProductByLabel } from './matcher';
import { sendTextMessage, sendButtonMessage, sendListMessage } from './messenger';
import { calculateLineTotals, calculateInvoiceTotals } from './tva-server';
import { createInvoiceFromWhatsApp } from './invoice-creator';
import { deliverInvoicePDF } from './pdf-delivery';
import { isInvoiceRateLimited, recordInvoiceCreation } from './webhook';
import { logActivity } from '../index';
if (!admin.apps.length) {
admin.initializeApp();
}
const db = admin.firestore();
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
const MAX_CONSECUTIVE_ERRORS = 3;
const MAX_MESSAGE_LENGTH = 1000;
const HELP_MESSAGE = `📋 Comment utiliser Fatura WhatsApp:
Créer une facture:
→ "Facture pour [client], [produit] [prix]dh"
→ "Facture pour Ahmed Benali, consulting 5000 MAD"
Options:
→ "sans TVA" pour une facture sans TVA
→ "TTC" si le prix inclut la TVA
→ "échéance 15 jours" pour changer l'échéance
Commandes:
→ "aide" — afficher ce message
→ "annuler" — annuler la session en cours
Le bot utilise les clients et produits de votre compte Fatura.`;
// ─────────────────────────────────────────────────────────────
// Session Management
// ─────────────────────────────────────────────────────────────
async function getOrCreateSession(businessId: string, waId: string): Promise<WhatsAppSession> {
const sessionsRef = db.collection(`businesses/${businessId}/whatsappSessions`);
const snapshot = await sessionsRef
.where('waId', '==', waId)
.orderBy('createdAt', 'desc')
.limit(1)
.get();
let session: WhatsAppSession | null = null;
if (!snapshot.empty) {
const s = snapshot.docs[0].data() as WhatsAppSession;
s.id = snapshot.docs[0].id;
const expiresAt = (s.expiresAt as any).toMillis ? (s.expiresAt as any).toMillis() : s.expiresAt;
if (expiresAt > Date.now() && s.state !== 'delivered') {
session = s;
}
}
if (!session) {
const newRef = sessionsRef.doc();
const expiresAt = admin.firestore.Timestamp.fromMillis(Date.now() + 30 * 60 * 1000);
const newSession: any = {
id: newRef.id,
businessId,
waId,
state: 'idle',
intentData: {},
resolvedData: {},
pendingField: null,
messageHistory: [],
invoiceId: null,
errorCount: 0,
currentOptions: [],
expiresAt,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
};
await newRef.set(newSession);
newSession.expiresAt = expiresAt;
session = newSession as WhatsAppSession;
await logActivity(businessId, "system", "WhatsApp: session démarrée", "whatsappSession", newRef.id, { waId });
}
return session;
}
async function updateSession(businessId: string, sessionId: string, data: Partial<WhatsAppSession>): Promise<void> {
const ref = db.collection(`businesses/${businessId}/whatsappSessions`).doc(sessionId);
await ref.update({
...data,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
}
async function resetSession(businessId: string, sessionId: string): Promise<void> {
const ref = db.collection(`businesses/${businessId}/whatsappSessions`).doc(sessionId);
await ref.update({
state: 'idle',
intentData: {},
resolvedData: {},
pendingField: null,
errorCount: 0,
currentOptions: [],
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
}
async function incrementErrorCount(businessId: string, session: WhatsAppSession): Promise<number> {
const newCount = (session.errorCount || 0) + 1;
await updateSession(businessId, session.id, { errorCount: newCount } as any);
return newCount;
}
async function addMessageToHistory(businessId: string, sessionId: string, role: 'user' | 'bot', content: string) {
const ref = db.collection(`businesses/${businessId}/whatsappSessions`).doc(sessionId);
await db.runTransaction(async (transaction) => {
const docSnap = await transaction.get(ref);
if (!docSnap.exists) return;
const session = docSnap.data() as WhatsAppSession;
const history = session.messageHistory || [];
history.push({
role,
content,
timestamp: admin.firestore.Timestamp.now() as any
});
if (history.length > 10) {
history.splice(0, history.length - 10);
}
transaction.update(ref, {
messageHistory: history,
updatedAt: admin.firestore.FieldValue.serverTimestamp()
});
});
}
// ─────────────────────────────────────────────────────────────
// Message Parsing Helpers
// ─────────────────────────────────────────────────────────────
function getReplyId(message: WebhookMessage, session: WhatsAppSession): string | null {
if (message.type === 'interactive') {
return message.interactive?.button_reply?.id || message.interactive?.list_reply?.id || null;
}
if (message.type === 'text') {
const text = message.text?.body?.trim();
if (!text) return null;
// Check if it's a number corresponding to an option
const index = parseInt(text, 10);
if (!isNaN(index) && session.currentOptions && index > 0 && index <= session.currentOptions.length) {
return session.currentOptions[index - 1];
}
// Also check if the text matches the option ID exactly (for robustness)
if (session.currentOptions?.includes(text)) {
return text;
}
}
return null;
}
// ─────────────────────────────────────────────────────────────
// Main Entry Point
// ─────────────────────────────────────────────────────────────
export async function processMessage(businessId: string, waId: string, message: WebhookMessage) {
let session: WhatsAppSession;
try {
session = await getOrCreateSession(businessId, waId);
} catch (err) {
logger.error('Failed to get/create session', { error: err, businessId, waId });
await safeSend(waId, "Erreur de base de données. Réessayez dans quelques instants.");
return;
}
try {
// ── Reject non-text/interactive messages ──
if (message.type !== 'text' && message.type !== 'interactive') {
await sendTextMessage(waId, "Je ne comprends que les messages texte pour l'instant. Envoyez votre demande en texte.");
return;
}
// ── Extract text content ──
const messageText = message.type === 'text' ? message.text?.body :
(message.interactive?.button_reply?.title || message.interactive?.list_reply?.title || message.interactive?.list_reply?.id);
if (!messageText || messageText.trim().length === 0) return;
if (messageText.length > MAX_MESSAGE_LENGTH) {
await sendTextMessage(waId, "Message trop long. Veuillez raccourcir votre message.");
return;
}
await addMessageToHistory(businessId, session.id, 'user', messageText);
const lowerText = messageText.toLowerCase().trim();
if (lowerText === 'aide' || lowerText === 'help') {
await sendTextMessage(waId, HELP_MESSAGE);
return;
}
if (lowerText === 'annuler') {
if (session.state !== 'idle') {
await resetSession(businessId, session.id);
await sendTextMessage(waId, "Session annulée.");
} else {
await sendTextMessage(waId, "Rien à annuler.");
}
return;
}
// ── State machine dispatch ──
switch (session.state) {
case 'idle':
case 'parsing_intent':
await handleParsingIntent(businessId, waId, session, messageText);
break;
case 'awaiting_client':
await handleAwaitingClient(businessId, waId, session, message);
break;
case 'creating_client':
await handleCreatingClient(businessId, waId, session, messageText);
break;
case 'awaiting_product':
await handleAwaitingProduct(businessId, waId, session, messageText);
break;
case 'awaiting_details':
await handleAwaitingDetails(businessId, waId, session, messageText);
break;
case 'confirming':
await handleConfirming(businessId, waId, session, message);
break;
case 'generating':
await sendTextMessage(waId, "⏳ Votre facture est en cours de génération, veuillez patienter.");
break;
case 'delivered':
case 'error':
await resetSession(businessId, session.id);
const freshSession = await getOrCreateSession(businessId, waId);
await handleParsingIntent(businessId, waId, freshSession, messageText);
break;
}
if ((session.errorCount || 0) > 0) {
await updateSession(businessId, session.id, { errorCount: 0 } as any);
}
} catch (error: any) {
logger.error('Error processing message', {
error: error.message,
stack: error.stack,
businessId,
sessionId: session.id,
state: session.state,
waId,
});
const errorCount = await incrementErrorCount(businessId, session);
if (errorCount >= MAX_CONSECUTIVE_ERRORS) {
await resetSession(businessId, session.id);
await safeSend(waId, "Plusieurs erreurs consécutives se sont produites. Votre session a été réinitialisée. Veuillez réessayer.");
} else {
await safeSend(waId, "Désolé, une erreur technique est survenue. Réessayez.");
}
}
}
async function safeSend(waId: string, text: string): Promise<void> {
try {
await sendTextMessage(waId, text);
} catch (err) {
logger.error('safeSend: failed to send error notification', { err, waId });
}
}
// ─────────────────────────────────────────────────────────────
// State Handlers
// ─────────────────────────────────────────────────────────────
async function handleParsingIntent(businessId: string, waId: string, session: WhatsAppSession, text: string) {
await sendTextMessage(waId, "⏳ Je m'en occupe ! Un instant...");
let intent: NlpIntent;
try {
intent = await parseInvoiceIntent(text, session.messageHistory);
} catch (err) {
logger.error('NLP API failure', { error: err, businessId });
await sendTextMessage(waId, "Désolé, une erreur technique est survenue. Réessayez.");
return;
}
if (intent.confidence < 0.5 && intent.intent === 'create_invoice') {
await sendTextMessage(waId, "Je n'ai pas bien compris. Essayez par exemple : \"Facture pour Ahmed, consulting 5000dh\"");
return;
}
if (intent.intent === 'create_invoice') {
await updateSession(businessId, session.id, {
state: 'parsing_intent',
intentData: intent.entities as any
});
if (intent.entities.clientName) {
await sendTextMessage(waId, `🔍 Recherche du client "${intent.entities.clientName}"...`);
const match = await findClientByName(businessId, intent.entities.clientName);
if (match.exact) {
await logActivity(businessId, "system", "WhatsApp: client résolu", "client", match.exact.id, {
method: "exact_match",
name: match.exact.name
});
await updateSession(businessId, session.id, {
state: 'awaiting_product',
resolvedData: { ...session.resolvedData, clientId: match.exact.id }
});
await processProductPhase(businessId, waId, session.id, intent);
} else if (match.fuzzy.length > 0) {
const options = match.fuzzy.slice(0, 5).map(c => `client_${c.id}`);
await updateSession(businessId, session.id, { state: 'awaiting_client', currentOptions: options });
const sections = [{
title: "Clients trouvés",
rows: match.fuzzy.slice(0, 5).map(c => ({ id: `client_${c.id}`, title: c.name, description: '' }))
}];
await sendListMessage(waId, `Plusieurs clients correspondent à "${intent.entities.clientName}". Lequel ?`, sections);
} else {
const options = ['create_client', 'retry_client'];
await updateSession(businessId, session.id, { state: 'awaiting_client', currentOptions: options });
await sendButtonMessage(waId, `Client "${intent.entities.clientName}" introuvable. Voulez-vous le créer ?`, [
{ id: 'create_client', title: 'Oui, créer' },
{ id: 'retry_client', title: 'Non, réessayer' }
]);
}
} else {
await updateSession(businessId, session.id, { state: 'awaiting_client', currentOptions: [] });
await sendTextMessage(waId, "Pour quel client voulez-vous créer la facture ?");
}
} else if (intent.intent === 'unknown') {
await sendTextMessage(waId, "Je n'ai pas compris. Essayez \"aide\" pour voir des exemples.");
}
}
async function handleAwaitingClient(businessId: string, waId: string, session: WhatsAppSession, message: WebhookMessage) {
const replyId = getReplyId(message, session);
if (replyId) {
if (replyId === 'create_client') {
await updateSession(businessId, session.id, { state: 'creating_client', currentOptions: [] });
await sendTextMessage(waId, "Entrez l'ICE du client (ou tapez \"passer\" pour ignorer) :");
} else if (replyId === 'retry_client') {
await updateSession(businessId, session.id, { currentOptions: [] });
await sendTextMessage(waId, "Quel est le nom du client ?");
} else if (replyId?.startsWith('client_')) {
const clientId = replyId.replace('client_', '');
await logActivity(businessId, "system", "WhatsApp: client résolu", "client", clientId, {
method: "list_selection",
waId
});
await updateSession(businessId, session.id, {
state: 'awaiting_product',
currentOptions: [],
resolvedData: { ...session.resolvedData, clientId }
});
await processProductPhase(businessId, waId, session.id, { entities: session.intentData } as any);
}
} else if (message.type === 'text') {
const text = message.text?.body;
if (!text) return;
const match = await findClientByName(businessId, text);
if (match.exact) {
await logActivity(businessId, "system", "WhatsApp: client résolu", "client", match.exact.id, {
method: "exact_match",
name: match.exact.name
});
await updateSession(businessId, session.id, {
state: 'awaiting_product',
currentOptions: [],
resolvedData: { ...session.resolvedData, clientId: match.exact.id }
});
await processProductPhase(businessId, waId, session.id, { entities: session.intentData } as any);
} else {
const options = ['create_client', 'retry_client'];
await updateSession(businessId, session.id, { currentOptions: options });
await sendButtonMessage(waId, `Client "${text}" introuvable.`, [
{ id: 'create_client', title: 'Créer nouveau' },
{ id: 'retry_client', title: 'Réessayer' }
]);
}
}
}
async function handleCreatingClient(businessId: string, waId: string, session: WhatsAppSession, text: string) {
let ice = text.trim();
if (ice.toLowerCase() === 'passer') {
ice = '';
}
const clientName = session.intentData.clientName || 'Nouveau Client';
const newClientRef = db.collection(`businesses/${businessId}/clients`).doc();
await newClientRef.set({
id: newClientRef.id,
businessId,
name: clientName,
ice,
totalInvoiced: 0,
totalPaid: 0,
balance: 0,
address: { street: '', city: '', postalCode: '', country: 'MA' },
createdAt: admin.firestore.FieldValue.serverTimestamp(),
updatedAt: admin.firestore.FieldValue.serverTimestamp()
});
await logActivity(businessId, "system", "WhatsApp: client résolu", "client", newClientRef.id, {
method: "created",
name: clientName
});
await updateSession(businessId, session.id, {
state: 'awaiting_product',
resolvedData: { ...session.resolvedData, clientId: newClientRef.id }
});
await sendTextMessage(waId, `Client "${clientName}" créé avec succès.`);
await processProductPhase(businessId, waId, session.id, { entities: session.intentData } as any);
}
async function processProductPhase(businessId: string, waId: string, sessionId: string, intent: NlpIntent) {
const sessionDoc = await db.collection(`businesses/${businessId}/whatsappSessions`).doc(sessionId).get();
const session = sessionDoc.data() as WhatsAppSession;
if (intent.entities.productLabel) {
const match = await findProductByLabel(businessId, intent.entities.productLabel);
const label = match.exact ? match.exact.label : intent.entities.productLabel;
if (intent.entities.unitPrice !== undefined && intent.entities.unitPrice !== null) {
const tvaRate = intent.entities.tvaOverride !== null ? intent.entities.tvaOverride : 20;
const quantity = intent.entities.quantity || 1;
let unitPrice = intent.entities.unitPrice;
if (intent.entities.priceType === 'TTC') {
unitPrice = Math.round((unitPrice * 100) / (100 + tvaRate));
}
const line: InvoiceLine = {
id: "temp_line_id",
description: label,
quantity,
unitPrice,
tvaRate: tvaRate as any,
totalHT: 0, totalTVA: 0, totalTTC: 0
};
const { taxableBase, totalTVA, totalTTC } = calculateLineTotals(unitPrice, quantity, tvaRate, undefined);
line.totalHT = taxableBase;
line.totalTVA = totalTVA;
line.totalTTC = totalTTC;
const lines = [line];
const totals = calculateInvoiceTotals(lines);
await updateSession(businessId, session.id, {
state: 'confirming',
resolvedData: { ...session.resolvedData, lines, totals }
});
await sendConfirmation(businessId, waId, session.id);
} else {
await updateSession(businessId, session.id, {
state: 'awaiting_details',
pendingField: 'price'
});
await sendTextMessage(waId, `Quel est le prix HT pour "${label}" ? (ex: 5000dh)`);
}
} else {
await updateSession(businessId, session.id, {
state: 'awaiting_details',
pendingField: 'product_label'
});
await sendTextMessage(waId, "Quelle est la description du produit/service ?");
}
}
async function handleAwaitingProduct(businessId: string, waId: string, session: WhatsAppSession, text: string) {
session.intentData.productLabel = text;
await updateSession(businessId, session.id, {
intentData: session.intentData
});
await processProductPhase(businessId, waId, session.id, { entities: session.intentData } as any);
}
async function handleAwaitingDetails(businessId: string, waId: string, session: WhatsAppSession, text: string) {
if (session.pendingField === 'product_label') {
session.intentData.productLabel = text;
await updateSession(businessId, session.id, { intentData: session.intentData });
await processProductPhase(businessId, waId, session.id, { entities: session.intentData } as any);
} else if (session.pendingField === 'price') {
const price = parseMoroccanPrice(text);
if (price <= 0) {
await sendTextMessage(waId, "Veuillez entrer un prix valide supérieur à 0.");
return;
}
session.intentData.unitPrice = price;
await updateSession(businessId, session.id, { intentData: session.intentData });
await processProductPhase(businessId, waId, session.id, { entities: session.intentData } as any);
}
}
async function sendConfirmation(businessId: string, waId: string, sessionId: string) {
const sessionDoc = await db.collection(`businesses/${businessId}/whatsappSessions`).doc(sessionId).get();
const session = sessionDoc.data() as WhatsAppSession;
const clientDoc = await db.collection(`businesses/${businessId}/clients`).doc(session.resolvedData.clientId!).get();
const clientName = clientDoc.data()?.name || "Client Inconnu";
const lines = session.resolvedData.lines || [];
const totals = session.resolvedData.totals;
if (lines.length === 0 || !totals) return;
const line = lines[0];
const tvaStr = line.tvaRate === 0 ? "Sans TVA" : `TVA ${line.tvaRate}%: ${(totals.totalTVA / 100).toFixed(2)}`;
const summary = `📋 Facture pour *${clientName}*:\n• ${line.description} — ${line.quantity} x ${(line.unitPrice / 100).toFixed(2)} MAD HT\n• ${tvaStr}\n• *Total TTC: ${(totals.totalTTC / 100).toFixed(2)} MAD*\n\nVoulez-vous la générer ?`;
const options = ['generate_invoice', 'modify_invoice', 'cancel_invoice'];
await updateSession(businessId, session.id, { currentOptions: options });
await sendButtonMessage(waId, summary, [
{ id: 'generate_invoice', title: '✅ Générer' },
{ id: 'modify_invoice', title: '✏️ Modifier' },
{ id: 'cancel_invoice', title: '❌ Annuler' }
]);
}
async function handleConfirming(businessId: string, waId: string, session: WhatsAppSession, message: WebhookMessage) {
const replyId = getReplyId(message, session);
if (replyId) {
if (replyId === 'cancel_invoice') {
await logActivity(businessId, "system", "WhatsApp: session annulée", "whatsappSession", session.id, { waId });
await resetSession(businessId, session.id);
await sendTextMessage(waId, "Facture annulée.");
} else if (replyId === 'modify_invoice') {
await sendModifyOptions(businessId, waId, session.id);
} else if (replyId === 'generate_invoice') {
await logActivity(businessId, "system", "WhatsApp: facture confirmée", "whatsappSession", session.id, { waId });
await generateAndDeliver(businessId, waId, session);
} else if (replyId?.startsWith('modify_')) {
await handleModifyField(businessId, waId, session, replyId);
}
} else if (message.type === 'text' && session.pendingField) {
const text = message.text?.body;
if (!text) return;
await handleModifyValue(businessId, waId, session, text);
}
}
async function sendModifyOptions(businessId: string, waId: string, sessionId: string) {
const options = ['modify_description', 'modify_price', 'modify_quantity', 'modify_tva'];
await updateSession(businessId, sessionId, { currentOptions: options });
const sections = [{
title: "Que voulez-vous modifier ?",
rows: [
{ id: 'modify_description', title: 'Description', description: 'Changer la description du service' },
{ id: 'modify_price', title: 'Prix unitaire', description: 'Changer le prix HT' },
{ id: 'modify_quantity', title: 'Quantité', description: 'Changer la quantité' },
{ id: 'modify_tva', title: 'TVA', description: 'Changer le taux de TVA' },
]
}];
await sendListMessage(waId, "Sélectionnez le champ à modifier :", sections);
}
async function handleModifyField(businessId: string, waId: string, session: WhatsAppSession, replyId: string) {
const fieldMap: Record<string, { field: string; prompt: string }> = {
'modify_description': { field: 'description', prompt: 'Entrez la nouvelle description :' },
'modify_price': { field: 'price', prompt: 'Entrez le nouveau prix HT (ex: 5000dh) :' },
'modify_quantity': { field: 'quantity', prompt: 'Entrez la nouvelle quantité :' },
'modify_tva': { field: 'tva', prompt: 'Entrez le nouveau taux de TVA (0, 7, 10, 14 ou 20) :' },
};
const mapping = fieldMap[replyId];
if (!mapping) return;
await updateSession(businessId, session.id, {
state: 'confirming',
pendingField: mapping.field,
currentOptions: [] // Clear options while waiting for text input
});
await sendTextMessage(waId, mapping.prompt);
}
async function handleModifyValue(businessId: string, waId: string, session: WhatsAppSession, text: string) {
const lines = session.resolvedData.lines || [];
if (lines.length === 0) {
await sendTextMessage(waId, "Erreur: aucune ligne à modifier.");
return;
}
const line = lines[0];
const field = session.pendingField;
if (field === 'description') {
line.description = text;
} else if (field === 'price') {
const price = parseMoroccanPrice(text);
if (price <= 0) {
await sendTextMessage(waId, "Prix invalide. Réessayez (ex: 5000dh) :");
return;
}
line.unitPrice = price;
} else if (field === 'quantity') {
const qty = parseInt(text, 10);
if (isNaN(qty) || qty <= 0) {
await sendTextMessage(waId, "Quantité invalide. Entrez un nombre supérieur à 0 :");
return;
}
line.quantity = qty;
} else if (field === 'tva') {
const tva = parseInt(text, 10);
if (![0, 7, 10, 14, 20].includes(tva)) {
await sendTextMessage(waId, "Taux de TVA invalide. Valeurs acceptées : 0, 7, 10, 14, 20");
return;
}
line.tvaRate = tva as any;
}
const { taxableBase, totalTVA, totalTTC } = calculateLineTotals(
line.unitPrice, line.quantity, line.tvaRate, undefined
);
line.totalHT = taxableBase;
line.totalTVA = totalTVA;
line.totalTTC = totalTTC;
const totals = calculateInvoiceTotals(lines);
await updateSession(businessId, session.id, {
state: 'confirming',
pendingField: null,
resolvedData: { ...session.resolvedData, lines, totals }
});
await sendConfirmation(businessId, waId, session.id);
}
// ─────────────────────────────────────────────────────────────
// Invoice Generation
// ─────────────────────────────────────────────────────────────
async function generateAndDeliver(businessId: string, waId: string, session: WhatsAppSession) {
if (isInvoiceRateLimited(businessId)) {
await sendTextMessage(waId, "Vous avez atteint le nombre maximum de factures pour cette heure. Réessayez plus tard.");
return;
}
await updateSession(businessId, session.id, { state: 'generating' });
try {
const invoice = await createInvoiceFromWhatsApp(businessId, session.resolvedData, session.intentData);
recordInvoiceCreation(businessId);
await logActivity(businessId, "system", "WhatsApp: facture créée", "invoice", invoice.id, {
invoiceNumber: invoice.number,
waId
});
await updateSession(businessId, session.id, {
state: 'generating',
invoiceId: invoice.id
});
await sendTextMessage(waId, `🚀 Facture ${invoice.number} créée ! Je génère maintenant le PDF...`);
deliverInvoicePDF(businessId, invoice.id, session.id, waId).catch(err => {
logger.error('Async PDF delivery failed', { error: err, businessId, invoiceId: invoice.id });
});
} catch (e: any) {
logger.error('Failed to generate invoice', {
error: e.message,
stack: e.stack,
businessId,
sessionId: session.id,
});
await logActivity(businessId, "system", "WhatsApp: erreur", "whatsappSession", session.id, {
error: e.message,
context: "generateAndDeliver"
});
await updateSession(businessId, session.id, { state: 'error' });
await sendTextMessage(waId, "Erreur lors de la création de la facture. Réessayez.");
}
}
import * as admin from 'firebase-admin';
import { Invoice, InvoiceCounter, Client, InvoiceType, InvoiceStatus } from '../../../src/types';
import * as logger from 'firebase-functions/logger';
if (!admin.apps.length) {
admin.initializeApp();
}
/**
* Creates an invoice specifically via the WhatsApp bot.
* Performs the same transactional logic as the client app.
*/
export async function createInvoiceFromWhatsApp(
businessId: string,
resolvedData: any, // Contains clientId, lines, totals
intentData: any
): Promise<Invoice> {
const db = admin.firestore();
const invoiceRef = db.collection(`businesses/${businessId}/invoices`).doc();
const counterRef = db.collection(`businesses/${businessId}/counters`).doc('invoice');
if (!resolvedData.clientId) {
throw new Error("Client ID is missing in resolvedData");
}
const clientRef = db.collection(`businesses/${businessId}/clients`).doc(resolvedData.clientId);
const currentYear = new Date().getFullYear();
return await db.runTransaction(async (transaction) => {
// 1. Reads
const counterDoc = await transaction.get(counterRef);
const clientDoc = await transaction.get(clientRef);
if (!clientDoc.exists) {
throw new Error(`Client ${resolvedData.clientId} not found`);
}
// 2. Logic
let nextNumber = 1;
if (counterDoc.exists) {
const c = counterDoc.data() as InvoiceCounter;
if (c.currentYear === currentYear) {
nextNumber = c.lastNumber + 1;
}
}
const paddedNum = nextNumber.toString().padStart(4, '0');
const invoiceNumber = `F-${currentYear}-${paddedNum}`;
// Default payment terms (30 days if not specified by intentData or business config)
// We'll just use 30 days default for the bot, or parse intentData.dueDate if available
let dueDateMillis = Date.now() + (30 * 24 * 60 * 60 * 1000);
// Wait, the intent parsing is a bit loose on dates for now, so let's stick to 30 days
const issueDate = admin.firestore.Timestamp.now();
const dueDate = admin.firestore.Timestamp.fromMillis(dueDateMillis);
const newInvoice: any = {
id: invoiceRef.id,
businessId,
clientId: resolvedData.clientId,
number: invoiceNumber,
type: 'facture' as InvoiceType,
status: 'sent' as InvoiceStatus, // Skip draft for WhatsApp
issueDate: issueDate,
dueDate: dueDate,
lines: resolvedData.lines || [],
totals: resolvedData.totals || {
totalHT: 0, tvaBreakdown: [], totalTVA: 0, totalTTC: 0
},
payments: [],
dgiStatus: null,
notes: intentData.notes || '',
createdBy: 'whatsapp-bot',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
};
// 3. Writes
transaction.set(invoiceRef, newInvoice);
transaction.set(counterRef, {
businessId,
currentYear,
lastNumber: nextNumber,
});
// Update client totals because status='sent'
const clientData = clientDoc.data() as Client;
const ttc = newInvoice.totals.totalTTC;
transaction.update(clientRef, {
totalInvoiced: (clientData.totalInvoiced || 0) + ttc,
balance: (clientData.balance || 0) + ttc,
updatedAt: admin.firestore.FieldValue.serverTimestamp()
});
return newInvoice as Invoice;
});
}
import * as admin from 'firebase-admin';
import { Client, Product } from '../../../src/types';
// Initialize the app if it hasn't been already
if (!admin.apps.length) {
admin.initializeApp();
}
/**
* Normalizes a string for matching: lowercase, trim, remove diacritics
*/
function normalizeString(str: string): string {
if (!str) return '';
return str
.toLowerCase()
.trim()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, ""); // Remove diacritics
}
/**
* Calculates the Jaro-Winkler similarity between two strings.
* Returns a value between 0.0 (completely different) and 1.0 (exact match).
*/
export function jaroWinklerSimilarity(s1: string, s2: string): number {
const str1 = normalizeString(s1);
const str2 = normalizeString(s2);
if (str1 === str2) return 1.0;
if (str1.length === 0 || str2.length === 0) return 0.0;
const mWeight = 0.1;
const matchWindow = Math.max(0, Math.floor(Math.max(str1.length, str2.length) / 2) - 1);
const matches1 = new Array(str1.length).fill(false);
const matches2 = new Array(str2.length).fill(false);
let matchCount = 0;
for (let i = 0; i < str1.length; i++) {
const start = Math.max(0, i - matchWindow);
const end = Math.min(i + matchWindow + 1, str2.length);
for (let j = start; j < end; j++) {
if (!matches2[j] && str1[i] === str2[j]) {
matches1[i] = true;
matches2[j] = true;
matchCount++;
break;
}
}
}
if (matchCount === 0) return 0.0;
let transpositions = 0;
let k = 0;
for (let i = 0; i < str1.length; i++) {
if (matches1[i]) {
while (!matches2[k]) k++;
if (str1[i] !== str2[k]) transpositions++;
k++;
}
}
transpositions /= 2;
const jaro = (
matchCount / str1.length +
matchCount / str2.length +
(matchCount - transpositions) / matchCount
) / 3;
let prefixLength = 0;
const maxPrefix = Math.min(4, Math.min(str1.length, str2.length));
for (let i = 0; i < maxPrefix; i++) {
if (str1[i] === str2[i]) {
prefixLength++;
} else {
break;
}
}
return jaro + (prefixLength * mWeight * (1 - jaro));
}
export interface MatchResult<T> {
exact: T | null;
fuzzy: T[];
none: boolean;
}
/**
* Finds a client by name using exact, prefix, and fuzzy matching.
*/
export async function findClientByName(businessId: string, name: string): Promise<MatchResult<Client>> {
if (!name) return { exact: null, fuzzy: [], none: true };
const db = admin.firestore();
const clientsRef = db.collection(`businesses/${businessId}/clients`);
// 1. Try exact match first
const exactSnapshot = await clientsRef.where('name', '==', name).limit(1).get();
if (!exactSnapshot.empty) {
const doc = exactSnapshot.docs[0];
return { exact: { id: doc.id, ...doc.data() } as Client, fuzzy: [], none: false };
}
// 2. Fetch all clients and apply Jaro-Winkler similarity
// In a massive database we'd use a text search engine like Algolia,
// but for typical SME client lists (e.g. < 500 clients), reading them all or using prefix bounds is fine.
// We'll read all active clients and sort by similarity.
const allClientsSnapshot = await clientsRef.get();
const fuzzyMatches: { client: Client, score: number }[] = [];
allClientsSnapshot.forEach(doc => {
const clientData = doc.data() as Omit<Client, 'id'>;
const score = jaroWinklerSimilarity(name, clientData.name);
// Threshold for acceptable similarity
if (score >= 0.75) {
fuzzyMatches.push({
client: { id: doc.id, ...clientData },
score
});
}
});
fuzzyMatches.sort((a, b) => b.score - a.score);
const fuzzy = fuzzyMatches.map(f => f.client);
return {
exact: null,
fuzzy,
none: fuzzy.length === 0
};
}
/**
* Finds a product by label using exact, prefix, and fuzzy matching.
*/
export async function findProductByLabel(businessId: string, label: string): Promise<MatchResult<Product>> {
if (!label) return { exact: null, fuzzy: [], none: true };
const db = admin.firestore();
const productsRef = db.collection(`businesses/${businessId}/products`);
// 1. Try exact match first
const exactSnapshot = await productsRef
.where('label', '==', label)
.where('isActive', '==', true)
.limit(1)
.get();
if (!exactSnapshot.empty) {
const doc = exactSnapshot.docs[0];
return { exact: { id: doc.id, ...doc.data() } as Product, fuzzy: [], none: false };
}
// 2. Fetch active products and apply Jaro-Winkler
const activeProductsSnapshot = await productsRef.where('isActive', '==', true).get();
const fuzzyMatches: { product: Product, score: number }[] = [];
activeProductsSnapshot.forEach(doc => {
const productData = doc.data() as Omit<Product, 'id'>;
const score = jaroWinklerSimilarity(label, productData.label);
if (score >= 0.75) {
fuzzyMatches.push({
product: { id: doc.id, ...productData },
score
});
}
});
fuzzyMatches.sort((a, b) => b.score - a.score);
const fuzzy = fuzzyMatches.map(f => f.product);
return {
exact: null,
fuzzy,
none: fuzzy.length === 0
};
}
import * as logger from 'firebase-functions/logger';
import { whatsappConfig } from './config';
import { Twilio } from 'twilio';
let twilioClient: Twilio | null = null;
function getTwilioClient() {
if (!twilioClient) {
if (whatsappConfig.twilioAccountSid === 'PLACEHOLDER_SID' || whatsappConfig.twilioAuthToken === 'PLACEHOLDER_TOKEN') {
logger.error('Twilio credentials not configured');
return null;
}
twilioClient = new Twilio(whatsappConfig.twilioAccountSid, whatsappConfig.twilioAuthToken);
}
return twilioClient;
}
/**
* Sends a text message via Twilio WhatsApp API.
* @param waId The recipient's WhatsApp ID (e.g., '33766455249')
* @param text The message content.
*/
export async function sendTextMessage(waId: string, text: string): Promise<string | undefined> {
const client = getTwilioClient();
if (!client) throw new Error('Twilio client not initialized');
const formattedTo = `whatsapp:+${waId.replace('whatsapp:', '').replace('+', '')}`;
try {
const message = await client.messages.create({
from: whatsappConfig.twilioPhoneNumber,
to: formattedTo,
body: text
});
logger.info('Message sent via Twilio', { sid: message.sid, to: formattedTo });
return message.sid;
} catch (error) {
logger.error('Failed to send message via Twilio', { error, to: formattedTo });
throw error;
}
}
/**
* Sends a document (PDF) via Twilio WhatsApp API.
* @param waId The recipient's WhatsApp ID.
* @param mediaUrl A publicly accessible URL to the document.
* @param filename The filename for the document.
* @param caption Optional caption.
*/
export async function sendDocumentMessage(waId: string, mediaUrl: string, filename: string, caption?: string): Promise<string | undefined> {
const client = getTwilioClient();
if (!client) throw new Error('Twilio client not initialized');
const formattedTo = `whatsapp:+${waId.replace('whatsapp:', '').replace('+', '')}`;
try {
const message = await client.messages.create({
from: whatsappConfig.twilioPhoneNumber,
to: formattedTo,
mediaUrl: [mediaUrl],
body: caption || filename
});
logger.info('Document sent via Twilio', { sid: message.sid, to: formattedTo, mediaUrl });
return message.sid;
} catch (error) {
logger.error('Failed to send document via Twilio', { error, to: formattedTo, mediaUrl });
throw error;
}
}
// Sandbox doesn't support interactive buttons well without templates.
// We'll map these to simple text responses for the user to reply to.
export async function sendButtonMessage(waId: string, bodyText: string, buttons: { id: string; title: string }[]) {
const options = buttons.map((b, i) => `[${i + 1}] ${b.title}`).join('\n');
const fullText = `${bodyText}\n\n${options}\n\nRépondez avec le chiffre correspondant.`;
return sendTextMessage(waId, fullText);
}
export async function sendListMessage(waId: string, bodyText: string, sections: any[]) {
let options = '';
let count = 1;
for (const section of sections) {
if (section.title) options += `*${section.title}*\n`;
for (const row of section.rows) {
options += `[${count++}] ${row.title}${row.description ? ` (${row.description})` : ''}\n`;
}
}
const fullText = `${bodyText}\n\n${options}\n\nRépondez avec le chiffre correspondant.`;
return sendTextMessage(waId, fullText);
}
export async function sendWelcomeMessage(waId: string) {
const welcomeText = `🎉 Bienvenue sur Fatura WhatsApp ! (via Twilio Sandbox)
Vous pouvez maintenant créer des factures en envoyant un simple message.
Exemple: "Facture pour Ahmed, consulting 5000dh"
Envoyez "aide" pour plus d'informations.`;
return sendTextMessage(waId, welcomeText);
}
import { GoogleGenerativeAI, Schema, SchemaType } from '@google/generative-ai';
import { whatsappConfig } from './config';
import * as logger from 'firebase-functions/logger';
import { NlpIntent } from '../../../src/types';
// Helper to parse Moroccan price formats into centimes (integer)
// Handles: "8500dh", "8 500 MAD", "8.500,00", "8500,50"
export function parseMoroccanPrice(priceStr: string): number {
if (!priceStr) return 0;
// Clean up the string: remove letters, currency symbols, and spaces
let cleaned = priceStr.replace(/[^\d.,]/g, '').trim();
// If we have something like "8.500,00" (dot for thousands, comma for decimals)
if (cleaned.includes(',') && cleaned.includes('.')) {
// Determine which is which. In Moroccan format, comma is usually decimal.
const lastCommaIndex = cleaned.lastIndexOf(',');
const lastDotIndex = cleaned.lastIndexOf('.');
if (lastCommaIndex > lastDotIndex) {
// 8.500,00 -> 8500.00
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
// 8,500.00 -> 8500.00
cleaned = cleaned.replace(/,/g, '');
}
} else if (cleaned.includes(',')) {
// Just a comma: "8500,50" -> 8500.50
cleaned = cleaned.replace(',', '.');
} else if (cleaned.includes('.')) {
// Just a dot. Could be thousands (8.500) or decimal (8500.50).
// If it has 3 digits after the dot, it's likely thousands.
const parts = cleaned.split('.');
if (parts[parts.length - 1].length === 3) {
cleaned = cleaned.replace(/\./g, '');
}
}
const value = parseFloat(cleaned);
if (isNaN(value)) return 0;
return Math.round(value * 100);
}
const nlpSchema: Schema = {
type: SchemaType.OBJECT,
properties: {
intent: {
type: SchemaType.STRING,
format: 'enum',
enum: ['create_invoice', 'check_status', 'cancel', 'help', 'unknown'],
description: "The primary action the user wants to perform.",
},
confidence: {
type: SchemaType.NUMBER,
description: "Confidence score between 0.0 and 1.0. Lower it if missing client name or price.",
},
entities: {
type: SchemaType.OBJECT,
properties: {
clientName: { type: SchemaType.STRING, nullable: true },
productLabel: { type: SchemaType.STRING, nullable: true },
quantity: { type: SchemaType.NUMBER, nullable: true },
unitPrice: { type: SchemaType.NUMBER, nullable: true, description: "Raw price amount extracted. We will convert it to centimes separately." },
currency: { type: SchemaType.STRING, format: 'enum', enum: ['MAD'], nullable: true },
tvaOverride: { type: SchemaType.NUMBER, nullable: true, description: "If they specify 'sans TVA', this is 0. Else use standard Moroccan rates: 20, 14, 10, 7." },
priceType: { type: SchemaType.STRING, format: 'enum', enum: ['HT', 'TTC'], nullable: true, description: "Default to HT if ambiguous." },
dueDate: { type: SchemaType.STRING, nullable: true, description: "ISO format date or null." },
notes: { type: SchemaType.STRING, nullable: true }
},
required: []
}
},
required: ["intent", "confidence", "entities"]
};
const SYSTEM_INSTRUCTION = `
You are an intent parser for a Moroccan invoicing app called Fatura.
Your job is to extract structured data from natural language French and Moroccan Arabic (Darija) messages.
Users will ask you to create invoices, like: "Facture pour Ahmed, logo design 8500dh".
Rules:
1. Extract clientName, productLabel, quantity, unitPrice (just the number), TVA overrides, and dates.
2. Default quantity to 1 if not specified but a product is mentioned.
3. Price formats: They might say "8500dh", "8500 MAD", "8.500,00", "huit mille". Extract the raw number, e.g. 8500.
4. "HT" = hors taxe (before tax), "TTC" = toutes taxes comprises (with tax). Default priceType to "HT" when ambiguous.
5. "sans TVA" or "0%" = tvaOverride: 0.
6. If the user says "aide" or "help", intent is "help". If "annuler", intent is "cancel".
7. Confidence: If intent is create_invoice but you can't find a clientName or unitPrice, lower confidence < 0.5.
8. Multi-line is possible. If multiple items are specified, for now, just extract the first prominent one or combine them into notes/productLabel.
`;
export async function parseInvoiceIntent(
message: string,
messageHistory: { role: 'user' | 'bot', content: string }[] = []
): Promise<NlpIntent> {
try {
const apiKey = whatsappConfig.geminiApiKey;
if (!apiKey || apiKey === 'PLACEHOLDER_GEMINI_KEY') {
logger.warn("Gemini API Key is missing or placeholder. NLP parsing will fail or return mock.");
throw new Error("GEMINI_API_KEY is not configured.");
}
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: {
responseMimeType: "application/json",
responseSchema: nlpSchema,
temperature: 0,
maxOutputTokens: 1000,
},
systemInstruction: SYSTEM_INSTRUCTION
});
// Format history as contents array
const contents: any[] = messageHistory.map(msg => ({
role: msg.role === 'bot' ? 'model' : 'user',
parts: [{ text: msg.content }]
}));
// Add current message
contents.push({
role: 'user',
parts: [{ text: `CURRENT MESSAGE TO PARSE: ${message}` }]
});
const result = await model.generateContent({ contents });
const text = result.response.text();
// LOG RAW TEXT FOR DEBUGGING
logger.info("Raw Gemini NLP Output", { text, message });
let parsed;
try {
parsed = JSON.parse(text);
} catch (parseError: any) {
logger.error("JSON Parse Error on Gemini Output", { text, error: parseError.message });
// Attempt manual extraction if JSON mode failed/wrapped
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
parsed = JSON.parse(jsonMatch[0]);
} else {
throw parseError;
}
}
// Convert parsed unitPrice to centimes
let unitPriceCentimes: number | null = null;
if (parsed.entities?.unitPrice !== undefined && parsed.entities?.unitPrice !== null) {
// Gemini gives us a number. e.g. 8500. Convert to centimes.
unitPriceCentimes = Math.round(parsed.entities.unitPrice * 100);
} else {
// Attempt to parse it from the original message if Gemini missed it but it was there?
// Usually Gemini gets the number right.
}
const intent: NlpIntent = {
intent: parsed.intent || 'unknown',
confidence: parsed.confidence || 0,
entities: {
clientName: parsed.entities?.clientName || null,
productLabel: parsed.entities?.productLabel || null,
quantity: parsed.entities?.quantity || null,
unitPrice: unitPriceCentimes,
currency: 'MAD',
tvaOverride: parsed.entities?.tvaOverride !== undefined ? parsed.entities.tvaOverride : null,
priceType: parsed.entities?.priceType || 'HT',
dueDate: parsed.entities?.dueDate || null,
notes: parsed.entities?.notes || null
}
};
logger.info("NLP Parse Result", { message, intent });
return intent;
} catch (error: any) {
logger.error("Error parsing NLP intent", {
error: error?.message || error,
status: error?.status,
details: error?.response?.data || error?.details,
message
});
return {
intent: 'unknown',
confidence: 0,
entities: {
clientName: null,
productLabel: null,
quantity: null,
unitPrice: null,
currency: 'MAD',
tvaOverride: null,
priceType: 'HT',
dueDate: null,
notes: null
}
};
}
}
import * as admin from 'firebase-admin';
import * as logger from 'firebase-functions/logger';
import { sendDocumentMessage, sendTextMessage } from './messenger';
import { logActivity } from '../index';
if (!admin.apps.length) {
admin.initializeApp();
}
const db = admin.firestore();
const bucket = admin.storage().bucket();
/**
* Polls for the generated PDF on an invoice, generates a signed URL,
* and sends it via Twilio WhatsApp.
*/
export async function deliverInvoicePDF(
businessId: string,
invoiceId: string,
sessionId: string,
waId: string
): Promise<void> {
const invoiceRef = db.collection(`businesses/${businessId}/invoices`).doc(invoiceId);
const sessionRef = db.collection(`businesses/${businessId}/whatsappSessions`).doc(sessionId);
try {
// Poll for pdfUrl on the invoice (check every 2 seconds, max 30 seconds)
let invoiceNumber = '';
let totalTTC = 0;
let pdfUrl = '';
let pdfUrlFound = false;
const maxAttempts = 15;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const invoiceDoc = await invoiceRef.get();
if (!invoiceDoc.exists) {
logger.error('Invoice not found during PDF delivery', { businessId, invoiceId });
break;
}
const invoiceData = invoiceDoc.data()!;
invoiceNumber = invoiceData.number || invoiceId;
totalTTC = invoiceData.totals?.totalTTC || 0;
if (invoiceData.pdfUrl) {
pdfUrl = invoiceData.pdfUrl;
pdfUrlFound = true;
break;
}
if (attempt === 3) {
await sendTextMessage(waId, "⚙️ Finalisation du document...");
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (pdfUrlFound) {
// Send the document via Twilio using the public URL
const totalFormatted = (totalTTC / 100).toFixed(2);
const caption = `✅ Votre facture ${invoiceNumber} est prête ! Montant: ${totalFormatted} MAD TTC.\nTransférez ce PDF à votre client.`;
await sendDocumentMessage(
waId,
pdfUrl,
`${invoiceNumber}.pdf`,
caption
);
logger.info('Invoice PDF delivered via Twilio', {
businessId, invoiceId, invoiceNumber, waId
});
await logActivity(businessId, "system", "WhatsApp: PDF envoyé", "invoice", invoiceId, {
invoiceNumber,
waId
});
} else {
logger.warn('PDF generation timed out for WhatsApp delivery', {
businessId, invoiceId
});
await sendFallbackMessage(waId, invoiceNumber);
}
await markSessionDelivered(sessionRef);
} catch (error) {
logger.error('Error delivering invoice PDF via WhatsApp', {
businessId, invoiceId, error
});
try {
await sendTextMessage(
waId,
`La facture a été créée mais l'envoi du PDF a échoué. Retrouvez-la dans l'application Fatura.`
);
} catch (sendError) {
logger.error('Failed to send fallback message', sendError);
}
await markSessionDelivered(sessionRef);
}
}
async function sendFallbackMessage(waId: string, invoiceNumber: string): Promise<void> {
await sendTextMessage(
waId,
`La facture ${invoiceNumber} a été créée mais le PDF prend du temps. Retrouvez-la dans l'application Fatura.`
);
}
async function markSessionDelivered(sessionRef: FirebaseFirestore.DocumentReference): Promise<void> {
try {
await sessionRef.update({
state: 'delivered',
updatedAt: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
logger.error('Failed to mark session as delivered', error);
}
}
import { InvoiceLine } from '../../../src/types';
/**
* Calculates raw integer parameters explicitly mapped to centimes structure for invoice rows.
* Disclosures apply percentage/fixed discounts chronologically onto base HT totals first, prior to TVA calculation.
*/
export function calculateLineTotals(
unitPrice: number, // Must be in centimes
quantity: number,
tvaRate: number,
discount?: { type: 'percentage' | 'fixed'; value: number }
) {
const totalHT = Math.round(quantity * unitPrice);
let discountAmount = 0;
if (discount) {
if (discount.type === 'percentage') {
discountAmount = Math.round((totalHT * discount.value) / 100);
} else {
// Fixed discount is strictly formatted in centimes
discountAmount = discount.value;
}
}
// Prevent massive discounts accidentally rolling negatively
discountAmount = Math.min(discountAmount, totalHT);
const taxableBase = totalHT - discountAmount;
const totalTVA = Math.round((taxableBase * tvaRate) / 100);
const totalTTC = taxableBase + totalTVA;
return { totalHT, discountAmount, taxableBase, totalTVA, totalTTC };
}
/**
* Accumulates lines via strict integer operations mapping the discrete tva breakages.
*/
export function calculateInvoiceTotals(lines: any[]) {
let invoiceTotalHT = 0;
let invoiceTotalTVA = 0;
let invoiceTotalTTC = 0;
const tvaBreakdownMap = new Map<number, { rate: number; base: number; amount: number }>();
for (const line of lines) {
const { taxableBase, totalTVA, totalTTC } = calculateLineTotals(
line.unitPrice,
line.quantity,
line.tvaRate,
line.discount
);
invoiceTotalHT += taxableBase;
invoiceTotalTVA += totalTVA;
invoiceTotalTTC += totalTTC;
const existing = tvaBreakdownMap.get(line.tvaRate) || { rate: line.tvaRate, base: 0, amount: 0 };
existing.base += taxableBase;
existing.amount += totalTVA;
tvaBreakdownMap.set(line.tvaRate, existing);
}
const tvaBreakdown = Array.from(tvaBreakdownMap.values()).sort((a, b) => a.rate - b.rate);
return {
totalHT: invoiceTotalHT,
tvaBreakdown,
totalTVA: invoiceTotalTVA,
totalTTC: invoiceTotalTTC
};
}
// functions/src/whatsapp/types.ts
// ---------------------------------------------------------
// INCOMING WEBHOOK TYPES
// ---------------------------------------------------------
export interface WebhookPayload {
object: string;
entry: WebhookEntry[];
}
export interface WebhookEntry {
id: string;
changes: WebhookChange[];
}
export interface WebhookChange {
value: {
messaging_product: string;
metadata: {
display_phone_number: string;
phone_number_id: string;
};
contacts?: Array<{
profile: {
name: string;
};
wa_id: string;
}>;
messages?: WebhookMessage[];
statuses?: any[];
};
field: string;
}
export interface WebhookMessage {
from: string; // The waId
id: string;
timestamp: string;
type: 'text' | 'interactive' | 'image' | 'document' | 'audio' | 'button' | 'unknown';
text?: {
body: string;
};
interactive?: {
type: 'button_reply' | 'list_reply';
button_reply?: {
id: string;
title: string;
};
list_reply?: {
id: string;
title: string;
description?: string;
};
};
button?: {
payload: string;
text: string;
};
}
// ---------------------------------------------------------
// OUTBOUND MESSAGE TYPES
// ---------------------------------------------------------
export interface OutboundMessage {
messaging_product: 'whatsapp';
recipient_type: 'individual';
to: string;
type: 'text' | 'interactive' | 'document';
text?: {
preview_url: boolean;
body: string;
};
interactive?: {
type: 'button' | 'list';
body: {
text: string;
};
action: {
buttons?: OutboundButton[];
button?: string; // For list messages
sections?: OutboundListSection[];
};
};
document?: {
id?: string;
link?: string;
caption?: string;
filename?: string;
};
}
export interface OutboundButton {
type: 'reply';
reply: {
id: string;
title: string;
};
}
export interface OutboundListSection {
title: string;
rows: Array<{
id: string;
title: string;
description?: string;
}>;
}
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as logger from 'firebase-functions/logger';
import { sendTextMessage } from './messenger';
import { processMessage } from './engine';
// Initialize admin if not already initialized
if (!admin.apps.length) {
admin.initializeApp();
}
// ─────────────────────────────────────────────────────────────
// In-memory deduplication (simple TTL set)
// ─────────────────────────────────────────────────────────────
const processedMessageIds = new Map<string, number>(); // messageId → timestamp
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
function isDuplicate(messageId: string): boolean {
if (processedMessageIds.size > 500) {
const now = Date.now();
for (const [id, ts] of processedMessageIds) {
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
}
}
if (processedMessageIds.has(messageId)) return true;
processedMessageIds.set(messageId, Date.now());
return false;
}
// ─────────────────────────────────────────────────────────────
// Rate Limiting — Sliding window counters (in-memory)
// ─────────────────────────────────────────────────────────────
interface RateWindow {
timestamps: number[];
}
const messageRateLimits = new Map<string, RateWindow>();
const MESSAGE_RATE_LIMIT = 60;
const MESSAGE_RATE_WINDOW_MS = 60 * 1000;
const invoiceRateLimits = new Map<string, RateWindow>();
const INVOICE_RATE_LIMIT = 20;
const INVOICE_RATE_WINDOW_MS = 60 * 60 * 1000;
function isRateLimited(
store: Map<string, RateWindow>,
businessId: string,
limit: number,
windowMs: number
): boolean {
const now = Date.now();
let window = store.get(businessId);
if (!window) {
window = { timestamps: [] };
store.set(businessId, window);
}
window.timestamps = window.timestamps.filter(ts => now - ts < windowMs);
if (window.timestamps.length >= limit) return true;
window.timestamps.push(now);
return false;
}
export function isMessageRateLimited(businessId: string): boolean {
return isRateLimited(messageRateLimits, businessId, MESSAGE_RATE_LIMIT, MESSAGE_RATE_WINDOW_MS);
}
export function isInvoiceRateLimited(businessId: string): boolean {
return isRateLimited(invoiceRateLimits, businessId, INVOICE_RATE_LIMIT, INVOICE_RATE_WINDOW_MS);
}
export function recordInvoiceCreation(businessId: string): void {
let window = invoiceRateLimits.get(businessId);
if (!window) {
window = { timestamps: [] };
invoiceRateLimits.set(businessId, window);
}
window.timestamps.push(Date.now());
}
/**
* Webhook Handler for Twilio WhatsApp
*/
export const whatsappWebhook = functions.region('europe-west1').https.onRequest(async (req, res) => {
// Twilio sends POST requests with form-urlencoded body
if (req.method !== 'POST') {
res.sendStatus(405);
return;
}
try {
const payload = req.body;
const messageId = payload.MessageSid;
const from = payload.From || ''; // format: whatsapp:+33766455249
const body = payload.Body || '';
// Extract waId (phone number without prefix)
const waId = from.replace('whatsapp:', '').replace('+', '');
if (!messageId || !waId) {
logger.warn('Invalid Twilio payload', { payload });
res.sendStatus(400);
return;
}
// ── Deduplication ──
if (isDuplicate(messageId)) {
logger.info('Duplicate message skipped', { messageId });
res.status(200).send('<Response></Response>');
return;
}
// ── Lookup business link ──
const linkDoc = await admin.firestore().collection('whatsappLinks').doc(waId).get();
if (!linkDoc.exists) {
logger.info('Message from unregistered number', { waId });
// We still need to respond with 200 to Twilio
res.status(200).send('<Response></Response>');
await sendTextMessage(waId, "Désolé, ce numéro n'est pas lié à un compte Fatura. Activez WhatsApp dans les Paramètres de l'application.");
return;
}
const linkData = linkDoc.data()!;
if (!linkData.isActive) {
logger.info('Message from inactive link', { waId });
res.status(200).send('<Response></Response>');
await sendTextMessage(waId, "Votre accès WhatsApp Fatura est actuellement désactivé. Réactivez-le dans les Paramètres.");
return;
}
const businessId = linkData.businessId;
// ── Rate Limiting ──
if (isMessageRateLimited(businessId)) {
logger.warn('Message rate limit hit', { businessId, waId });
res.status(200).send('<Response></Response>');
await sendTextMessage(waId, "Vous envoyez trop de messages. Réessayez dans une minute.");
return;
}
// Update last message timestamp
linkDoc.ref.update({
lastMessageAt: admin.firestore.FieldValue.serverTimestamp()
}).catch(err => logger.warn('Failed to update lastMessageAt', { err }));
logger.info('Processing Twilio message', { waId, businessId, messageId });
// Transform Twilio payload to the format expected by engine.ts
// engine.ts expects: { id, from, type: 'text', text: { body } }
const engineMessage = {
id: messageId,
from: waId,
type: 'text',
text: { body: body }
};
// Acknowledge to Twilio immediately
res.status(200).send('<Response></Response>');
try {
await processMessage(businessId, waId, engineMessage as any);
} catch (err) {
logger.error('Error in processMessage', { error: err, businessId, waId, messageId });
await sendTextMessage(waId, "Une erreur est survenue lors du traitement de votre message. Réessayez.");
}
} catch (error) {
logger.error('Error processing Twilio webhook', error);
res.sendStatus(500);
}
});
{
"compilerOptions": {
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": false,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2018",
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"compileOnSave": true,
"include": ["src"]
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1B4965" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Apple Mobile Web App Tags -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Fatura" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<!-- iOS Standalone Link Handling -->
<script>
(function(document, navigator, standalone) {
if ((standalone in navigator) && navigator[standalone]) {
var curnode, location = document.location, stop = /^(a|html)$/i;
document.addEventListener('click', function(e) {
curnode = e.target;
while (!(stop.test(curnode.nodeName))) {
curnode = curnode.parentNode;
}
if ('href' in curnode && (curnode.href.indexOf('http') || ~curnode.href.indexOf(location.host)) && (!curnode.classList.contains('external'))) {
e.preventDefault();
location.href = curnode.href;
}
}, false);
}
})(document, window.navigator, 'standalone');
</script>
<title>Fatura - Logiciel de Facturation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "fatura",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"firebase": "^12.12.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
{
"name": "Fatura - Logiciel de Facturation",
"short_name": "Fatura",
"description": "Logiciel de facturation moderne pour les entreprises marocaines.",
"start_url": "/",
"display": "standalone",
"background_color": "#F8F9FA",
"theme_color": "#1B4965",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"orientation": "portrait",
"scope": "/"
}
React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see this documentation.
Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])You can also install eslint-plugin-react-x and eslint-plugin-react-dom for React-specific lint rules:
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]).counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
import { NavLink } from 'react-router-dom';
import {
Home,
FileText,
Package,
Users,
Settings
} from 'lucide-react';
export default function MobileNav() {
return (
<div className="bg-white/80 backdrop-blur-lg border-t border-slate-200/60 pb-safe shadow-[0_-4px_24px_rgba(0,0,0,0.06)]">
<div className="flex justify-around items-center px-1 pt-3 pb-3 max-w-md mx-auto">
<NavItem to="/" icon={Home} label="Accueil" />
<NavItem to="/invoices" icon={FileText} label="Factures" />
<NavItem to="/products" icon={Package} label="Produits" />
<NavItem to="/clients" icon={Users} label="Clients" />
<NavItem to="/settings" icon={Settings} label="Paramètres" />
</div>
</div>
);
}
function NavItem({ to, icon: Icon, label }: { to: string; icon: any; label: string }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
`flex flex-col items-center justify-center w-16 gap-1 transition-all duration-300 ease-out ${
isActive
? 'text-[#1B4965] scale-105'
: 'text-slate-400 hover:text-slate-600 hover:scale-105'
}`
}
>
{({ isActive }) => (
<>
<div className={`relative flex items-center justify-center w-8 h-8 rounded-full transition-colors duration-300 ${isActive ? 'bg-[#1B4965]/10' : 'bg-transparent'}`}>
<Icon
className={`w-5 h-5 transition-all duration-300 ${
isActive ? 'fill-[#1B4965]/20 stroke-[2.5px]' : 'stroke-2'
}`}
/>
{isActive && (
<span className="absolute -bottom-1 w-1 h-1 rounded-full bg-[#1B4965]" />
)}
</div>
<span className={`text-[10px] leading-tight transition-all duration-300 ${
isActive ? 'font-bold' : 'font-medium'
}`}>
{label}
</span>
</>
)}
</NavLink>
);
}
export const fr = {
common: {
dashboard: 'Tableau de bord',
invoices: 'Factures',
clients: 'Clients',
products: 'Produits',
settings: 'Paramètres',
logout: 'Déconnexion',
save: 'Enregistrer',
cancel: 'Annuler',
delete: 'Supprimer',
edit: 'Modifier',
create: 'Créer',
search: 'Rechercher...',
},
invoices: {
newInvoice: 'Nouvelle Facture',
number: 'N° Facture',
date: 'Date',
dueDate: 'Date d\'échéance',
status: 'Statut',
totalHT: 'Total HT',
totalTTC: 'Total TTC',
tva: 'TVA',
actions: 'Actions',
client: 'Client',
draft: 'Brouillon',
sent: 'Envoyée',
paid: 'Payée',
},
};
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}