Architecture
Monorepo Turborepo — 3 applications, 1 base de données partagée
apps/api — NestJS 10
API REST · JWT RS256 · Guards globaux · Modules : auth, vouchers, ledger, payments, merchants, companies, users, notifications
apps/web — Next.js 15
App Router · 3 espaces : bénéficiaire (/app), entreprise (/dashboard), commerçant (/pos) · PWA avec Service Worker
packages/shared
Types TypeScript partagés · DTOs · Validateurs communs entre l'API et le web

Flux de données

# Bénéficiaire
app/wallet → GET /api/v1/vouchers/me → PostgreSQL (table vouchers)

# Validation QR commerçant
/pos/scan → POST /api/v1/vouchers/validate
  → SELECT FOR UPDATE (atomique)
  → UPDATE vouchers (remainingValue, status)
  → INSERT voucher_transactions (displayRef, amount, commission)
  → INSERT ledger_entries ×2 (VOUCHER_LIABILITY → MERCHANT_PAYABLE)

# Reversement T+1
Cron 23h00 UTC → Bull Queue → Wave API / Orange Money API
  → INSERT merchant_settlements + ledger_entries (SETTLE)
Stack & Ports
Versions et ports de développement local
API (NestJS)
http://localhost:3001
Web (Next.js)
http://localhost:3000
PostgreSQL
localhost:5432 · database : kado
Redis
localhost:6379
Prisma Studio
http://localhost:5555 (via npx prisma studio)
API production
https://kadoapi-production.up.railway.app
Web production
https://kado.sn · kado.app · kado.africa
DB production
Railway PostgreSQL 16 — URL via railway variables
Redis production
Railway Redis 7
Déploiement
Chaque push sur main déclenche un redéploiement automatique Railway
Script de build Railway Le build exécute automatiquement prisma migrate deploy avant nest build. Les migrations sont appliquées à chaque déploiement.

Workflow standard

# 1. Développer en local
npm run dev                     # API (3001) + Web (3000) en parallèle

# 2. Vérifier les types et le lint
npm run typecheck && npm run lint

# 3. Créer une nouvelle migration si le schéma a changé
npx prisma migrate dev --name "description_courte"
# ⚠ Créer aussi prisma/migrations/{nom}/down.sql immédiatement

# 4. Commit et push → Railway redéploie automatiquement
git add . && git commit -m "feat: ..."
git push origin main

# 5. Suivre les logs de déploiement
railway logs --tail
Migrations de base de données
Prisma Migrate — historique et convention de nommage
Règle absolue Tout fichier migration.sql doit être accompagné d'un down.sql dans le même dossier, créé au moment de la migration.
MigrationDescriptionDown disponible
20260430_add_vendor_and_display_refTable vendors, colonnes displayRef/vendorId sur voucher_transactionsOui
20260426_add_merchant_apikey_prefixColonne apiKeyPrefix sur merchantsOui
20260425_add_wa_sessionTable wa_sessions (session Baileys WhatsApp)Oui
20260422_add_merchant_apikey_and_payrequest_ecommerceapiKey merchant + champs orderId/callbackUrl/description sur PayRequestOui
20260420_confirm_pending_vouchersMigration neutralisée (no-op)Oui
20260418_recharge_settlement_statusEnum SettlementStatus, champs de suivi sur merchant_settlementsOui
20260416_subscription_managementEnum SubscriptionStatus, table saas_payments, abonnement sur CompanyOui
20260414_add_pay_requests_and_pinTable pay_requests, colonne pinHash sur UserÀ créer
20260325_add_company_payment_treasuryTable company_payments, trésorerieÀ créer
20260316_initSchéma initial completNon requis

Convention down.sql

-- Exemple : prisma/migrations/20260430_xxx/down.sql

-- Toujours dans l'ordre inverse du up.sql :
-- 1. Supprimer les contraintes étrangères
ALTER TABLE "voucher_transactions" DROP CONSTRAINT IF EXISTS "voucher_transactions_vendorId_fkey";

-- 2. Supprimer les index
DROP INDEX IF EXISTS "voucher_transactions_merchantId_createdAt_idx";

-- 3. Supprimer les colonnes
ALTER TABLE "voucher_transactions" DROP COLUMN IF EXISTS "vendorId";

-- 4. Supprimer les tables
DROP TABLE IF EXISTS "vendors";
Ledger comptable
Double-entrée immuable — INSERT ONLY, jamais UPDATE ni DELETE
Invariant absolu Pour chaque opération : SUM(débit) = SUM(crédit). Tous les montants en centimes FCFA (Int). Jamais de Float.
CompteTypeDescription
PROVISION_COMPANY:{id}PassifFonds prépayés par l'entreprise
VOUCHER_LIABILITY:{id}PassifEngagement envers le bénéficiaire
MERCHANT_PAYABLE:{id}PassifMontant dû au commerçant
REVENUE_COMMISSIONProduitCommission kado (5%)
REVENUE_SAASProduitAbonnements SaaS
MERCHANT_SETTLED:{id}ActifMontant versé au commerçant
EXPIRED_FORFEITProduitSolde bons expirés (forfait)

Écritures types

OpérationDébitCrédit
ISSUE Émission bonPROVISION_COMPANY:{cId}VOUCHER_LIABILITY:{vId}
REDEEM Validation QRVOUCHER_LIABILITY:{vId}MERCHANT_PAYABLE:{mId} (net) + REVENUE_COMMISSION (5%)
EXPIRE ExpirationVOUCHER_LIABILITY:{vId}EXPIRED_FORFEIT
CANCEL Annulation RHVOUCHER_LIABILITY:{vId}PROVISION_COMPANY:{cId}
SETTLE ReversementMERCHANT_PAYABLE:{mId}MERCHANT_SETTLED:{mId}
Système de Rollback
Retour en arrière sécurisé — code + base de données — sans perte d'historique git
Prérequis Avoir psql et railway CLI installés et être authentifié : railway login

Commandes

# Rollback complet (code + DB) — interactif
npm run rollback

# Rollback base de données uniquement
npm run rollback:db

# Rollback code uniquement (sans toucher à la DB)
npm run rollback:code

# Ou directement :
bash scripts/rollback.sh
bash scripts/rollback.sh --db-only
bash scripts/rollback.sh --code-only

Ce que fait le script

1
Affichage des 12 derniers commits
Liste numérotée avec hash court et message. Vous choisissez le numéro de la version cible.
2
Détection automatique des migrations concernées
Analyse les fichiers migration.sql modifiés entre le commit actuel et la cible. Liste les down.sql à exécuter dans l'ordre inverse.
3
Rollback base de données (psql direct sur Railway)
Exécute les down.sql via psql sur la base Railway. Supprime les entrées correspondantes dans _prisma_migrations pour que Prisma ne considère plus ces migrations comme appliquées.
4
Revert git (historique préservé)
Crée un commit de revert pour chaque commit concerné. L'historique git reste intact — jamais de reset --hard ou push --force.
5
Push → redéploiement Railway automatique
git push origin main déclenche le pipeline Railway. Le build ré-exécute prisma migrate deploy (déjà cohérent avec le down.sql exécuté).
Données irréversibles Les entrées du ledger (ledger_entries) sont INSERT ONLY. Un rollback DB ne supprime PAS les écritures comptables passées. Pour une correction, créer une écriture corrective.
Commandes utiles
Référence rapide pour les opérations courantes
# ── Développement local ──────────────────────────────────────
npm run dev                        # API + Web en parallèle
docker-compose up -d               # PostgreSQL + Redis
npx prisma studio                  # Interface graphique DB
npx prisma migrate dev             # Nouvelle migration locale
npx prisma db seed                 # Fixtures de test

# ── Tests ────────────────────────────────────────────────────
npm run test:unit                  # Vitest avec coverage
npm run test:e2e                   # Playwright E2E
npm run test:load                  # k6 — 100 validations simultanées

# ── Production Railway ───────────────────────────────────────
railway logs --tail                # Logs en temps réel
railway variables                  # Variables d'environnement
railway run npx prisma migrate deploy # Migration manuelle en prod

# ── Rollback ─────────────────────────────────────────────────
npm run rollback                   # Rollback interactif (code + DB)
npm run rollback:db                # DB uniquement
npm run rollback:code              # Code uniquement

# ── Génération clés JWT RS256 ────────────────────────────────
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -out public.pem
openssl rand -hex 32               # HMAC_VOUCHER_SECRET
Codes d'erreur — Signification & Solutions
Tous les codes d'erreur renvoyés par l'API kado, avec causes et recours

Erreurs bons (vouchers)

CodeHTTPSignificationCause probableRecours
VOUCHER_EXPIRED 409 Bon expiré La date expiresAt est dépassée (validité J+180 par défaut) Contacter le service RH de l'entreprise émettrice pour réémission. Vérifier le cron d'expiration (00h01 UTC).
VOUCHER_ALREADY_USED 409 Bon épuisé Le statut est USED — solde à 0 Vérifier l'historique des transactions sur ce bon. Si erreur, contacter support.
INSUFFICIENT_BALANCE 409 Solde insuffisant Le montant demandé dépasse remainingValue Réduire le montant de la transaction. Afficher le solde restant au bénéficiaire (API lookup).
QR_INVALID 400 Signature HMAC incorrecte QR scanné corrompu, falsifié, ou HMAC_VOUCHER_SECRET différent entre émission et validation Vérifier que HMAC_VOUCHER_SECRET est identique sur tous les services. Ne jamais régénérer cette clé en production sans re-signer tous les bons.
VOUCHER_NOT_FOUND 404 Bon introuvable ID/code incorrect, ou bon supprimé (ne devrait pas arriver) Vérifier l'UUID dans la base. Confirmer que le QR n'est pas tronqué.
TYPE_NOT_ALLOWED 409 Type bon incompatible commerçant Bon de type MEAL_TICKET présenté chez un commerçant GENERAL Vérifier la catégorie du commerçant et le type du bon. Configurer le commerçant dans l'admin.
VOUCHER_NOT_YOURS 409 Bon n'appartient pas à ce bénéficiaire Le beneficiaryPhone ou beneficiaryId ne correspond pas IDOR — vérifier les guards. Le bénéficiaire doit se connecter avec le numéro associé au bon.

Erreurs provisions & émission

CodeHTTPSignificationCause probableRecours
INSUFFICIENT_PROVISION 409 Provision entreprise épuisée Le solde Company.provisionBalance est inférieur au montant total du lot Demander à l'entreprise de recharger sa provision via Wave ou Orange Money. Vérifier dans l'admin dashboard.
LIMIT_EXCEEDED 422 Plafond légal IRPP dépassé Le montant des bons dépasse le plafond légal de déductibilité fiscale sénégalais Fractionner l'émission. Contacter le service juridique de l'entreprise.
SUBSCRIPTION_EXPIRED 402 Abonnement SaaS expiré Le subscriptionStatus est OVERDUE ou SUSPENDED Régulariser le paiement SaaS dans l'admin kado. Mettre à jour subscriptionPaidUntil.

Erreurs transactions & paiements

CodeHTTPSignificationCause probableRecours
DUPLICATE_TRANSACTION 409 Doublon détecté (idempotence) La référence UUID de la transaction existe déjà dans ledger_entries Rejouer avec un nouvel UUID de référence. Vérifier si la transaction initiale a abouti avant de rejouer.
TXR_EXPIRED 404 Transaction request expirée Le token Redis txreq:{token} a expiré (TTL 180s) Le commerçant doit créer une nouvelle transaction request. Afficher un message d'expiration côté bénéficiaire.
TXR_ALREADY_USED 409 Transaction déjà traitée Le statut Redis est paid — double soumission Le paiement a déjà été effectué. Afficher la confirmation. Ne pas rejouer.
CONFIRM_EXPIRED 409 Session de confirmation expirée Le confirmationId Redis a expiré (TTL 300s) Recommencer le flux depuis POST /vouchers/initiate-pay.
CONFIRM_INVALID 409 Mot de passe incorrect Le bénéficiaire a saisi un mauvais mot de passe kado Permettre 3 tentatives puis bloquer 5 min. Proposer la réinitialisation du mot de passe.

Erreurs authentification (OTP / JWT)

CodeHTTPSignificationCause probableRecours
OTP_BLOCKED 429 Trop de tentatives OTP 3 échecs en moins d'1 heure → blocage 30 min (otp_blocked:{phone} Redis) Attendre 30 min. En urgence prod : redis-cli DEL otp_blocked:+221XXXXXXXXX
OTP_INVALID 400 Code OTP incorrect ou expiré TTL Redis de 5 min dépassé ou code mal saisi Demander un nouveau code. Vérifier la livraison SMS via Nexah.
RATE_LIMIT_EXCEEDED 429 Trop de requêtes Dépasse 100 req/min (global) ou 30/min sur /auth/otp Implémenter un backoff exponentiel côté client. Vérifier les bots.
TOKEN_BLACKLISTED 401 Token JWT révoqué Token dans la blacklist Redis (déconnexion explicite ou rotation) Se reconnecter. Le token ne peut pas être réutilisé.

Erreurs webhooks paiement

CodeHTTPSignificationCause probableRecours
WEBHOOK_SIGNATURE_INVALID 401 Signature HMAC webhook invalide Header x-wave-signature ne correspond pas au body (clé erronée ou body altéré) Vérifier WAVE_WEBHOOK_SECRET et OM_WEBHOOK_SECRET. Ne jamais logger ni traiter un webhook à signature invalide.
WEBHOOK_ALREADY_PROCESSED 409 Webhook déjà traité (idempotence) La référence existe dans webhook_logs Répondre 200 sans retraiter. Comportement normal sur les retry Wave/OM.

Erreurs système

CodeHTTPSignificationCause probableRecours
INTERNAL_ERROR 500 Erreur interne serveur Exception non gérée — voir logs Sentry/Railway railway logs --tail. Vérifier Sentry DSN. Si récurrent : rollback.
DB_TIMEOUT 503 Timeout transaction DB Transaction atomique dépasse 5s (SELECT FOR UPDATE bloqué) Vérifier les locks PostgreSQL : SELECT * FROM pg_locks;. Augmenter le timeout si justifié.
REDIS_UNAVAILABLE 503 Redis inaccessible Railway Redis redémarré ou REDIS_URL incorrect Vérifier l'état Railway. L'API ne démarre pas sans Redis (OTP et blacklist JWT inaccessibles).
Endpoints API
Base URL : https://kadoapi-production.up.railway.app/api/v1

Authentification

MéthodeRouteDescriptionAuth
POST/auth/otp/sendEnvoyer OTP par SMSPublic
POST/auth/otp/verifyVérifier OTP → JWTPublic
POST/auth/refreshRenouveler access tokenRefresh token
POST/auth/logoutRévoquer les tokensJWT

Bons (vouchers)

MéthodeRouteDescriptionRôle
GET/vouchers/meBons du bénéficiaire connectéBENEFICIARY
GET/vouchers/:idDétail d'un bonJWT
GET/vouchers/:id/transactionsHistorique des utilisationsJWT
POST/vouchers/lookupPreview QR sans débitMERCHANT|ADMIN
POST/vouchers/validateValider QR — débit atomiqueMERCHANT
GET/vouchers/transaction-request/:tokenInfos transaction requestBENEFICIARY
POST/vouchers/initiate-payInitier paiement avec confirmationBENEFICIARY
POST/vouchers/confirm-payConfirmer avec mot de passeBENEFICIARY

Commerçants (espace POS)

MéthodeRouteDescriptionRôle
GET/merchants/me/dashboardRésumé du jour + transactionsMERCHANT
GET/merchants/me/transactionsJournal paginé avec filtres dateMERCHANT
POST/merchants/me/transaction-requestCréer une demande de paiementMERCHANT
GET/merchants/me/flash-offersLister les offres flashMERCHANT
POST/merchants/me/flash-offersCréer une offre flashMERCHANT
PUT/merchants/me/flash-offers/:idModifier une offre flashMERCHANT
DELETE/merchants/me/flash-offers/:idSupprimer une offre flashMERCHANT
GET/merchants/me/vendorsLister les vendeursMERCHANT
POST/merchants/me/vendorsAjouter un vendeurMERCHANT
PUT/merchants/me/vendors/:idModifier un vendeurMERCHANT
DELETE/merchants/me/vendors/:idDésactiver un vendeurMERCHANT
GET/merchants/nearbyCommerçants proches (géo)Public
Sécurité
Règles de sécurité non négociables
Signatures QR
HMAC-SHA256 avec timingSafeEqual — jamais de comparaison ===
JWT
RS256 · Access token 15 min · Refresh token 30j haché SHA-256 en DB · Rotation stricte de famille
OTP
6 chiffres · TTL 5 min Redis · Max 3 envois/h/numéro · Blocage 30 min après 3 échecs
Webhooks
Vérification HMAC avant tout parsing JSON · Rejeter sans traitement si invalide
Race conditions
SELECT FOR UPDATE dans toutes les transactions de validation QR
Throttling
100 req/min/IP global · 30/min sur /auth/otp
Secrets
100% variables d'environnement · Zéro hardcode · Ne jamais commiter .env
Cycle de vie des bons
Transitions de statut autorisées
PENDINGISSUED    (webhook EME confirme emeConfirmedAt)
ISSUEDPARTIAL   (paiement partiel — remainingValue > 0)
ISSUEDUSED      (paiement total — remainingValue = 0)
ISSUEDEXPIRED   (cron 00h01 UTC — expiresAt dépassé)
ISSUEDCANCELLED (annulation RH — remboursement provision)
PARTIALUSED      (solde épuisé)
PARTIALEXPIRED   (cron expiration)
USEDterminal (aucune transition)
EXPIREDterminal (aucune transition)
CANCELLEDterminal (aucune transition)
Montants Toujours en centimes FCFA (Int). 10 000 FCFA = 1 000 000 centimes. Affichage : amount / 100. Commission : Math.round(amount * 0.05)
Paiements Wave & Orange Money
Recharge provision entreprise · Reversements T+1 · Queue Bull asynchrone

Wave Checkout API — recharge provision entreprise

Quand une entreprise recharge sa provision via Wave, kado utilise l'API Wave Checkout (pas l'API EME directe). Le flux est le suivant :

1
Création de la session Checkout
POST /companies/me/topup avec { method: "WAVE", amountCentimes }. kado crée un enregistrement CompanyPayment puis appelle POST https://api.wave.com/v1/checkout/sessions avec le montant en XOF (FCFA, sans décimales). La session Wave renvoie un wave_launch_url.
2
Redirection et paiement
Le frontend redirige l'utilisateur vers le wave_launch_url. L'utilisateur paie via Wave Mobile (ou Wave Web). La référence idempotente est l'UUID du CompanyPayment (client_reference).
3
Webhook de confirmation
Wave envoie un webhook POST /api/v1/payments/webhooks/wave. Le champ checkout_status (vs event pour les webhooks EME) identifie les webhooks Checkout. Si checkout_status === "complete", kado incrémente provisionBalance de façon atomique et marque le CompanyPayment en RECU.
// Payload webhook Wave Checkout (exemple)
{
  "checkout_status": "complete",
  "client_reference": "uuid-company-payment-id",
  "amount": 50000,          // en XOF (FCFA)
  "currency": "XOF"
}

// Payload webhook Wave EME direct (exemple — activation voucher)
{
  "event": "checkout.session.completed",
  "data": { ... }
}
Distinction des deux types de webhooks Wave Le champ checkout_status indique un webhook Checkout API (recharge provision). Le champ event indique un webhook EME direct (activation de voucher). Le service processWaveWebhook() les route automatiquement.

Reversements commerçants

Cron reversement
23h00 UTC · Bull Queue · 3 tentatives · Backoff exponentiel 60s
Référence idempotence
SETTLE-{merchantId}-{YYYYMMDD} · Unique en base
Commission
5% · Math.round(amount * 0.05) · Compte REVENUE_COMMISSION
Webhook Wave
POST /payments/webhooks/wave · Header x-wave-signature · HMAC-SHA256 sur rawBody
Webhook Orange Money
POST /payments/webhooks/orange-money · Header x-om-signature
Wave prioritaire
Orange Money utilisé en fallback si Wave indisponible
Jamais d'appel synchrone Toutes les instructions de paiement EME passent par la queue Bull asynchrone. Ne jamais appeler directement l'API Wave ou OM depuis une requête HTTP entrante. La vérification HMAC doit se faire sur le rawBody brut, avant tout parsing JSON (rawBody: true activé dans NestFactory).
Terminal POS — deux modèles de validation
Conformité BCEAO — le bénéficiaire initie toujours le paiement
Règle BCEAO Conformément à la réglementation de la BCEAO sur la monnaie électronique, c'est le bénéficiaire qui initie le paiement. Les deux modèles ci-dessous respectent cette contrainte.

Modèle A — Le commerçant scanne le QR bénéficiaire (legacy)

Le commerçant utilise le scanner du POS pour lire le QR affiché sur l'écran du bénéficiaire. Il saisit le montant et valide. Flux : POST /vouchers/validate depuis le compte MERCHANT.

Modèle B — Le bénéficiaire scanne le terminal (conforme BCEAO)

Le commerçant saisit le montant sur son POS (POST /merchants/me/transaction-request) qui génère un token Redis (TTL 180s) et un QR. Le bénéficiaire scanne ce QR avec son app, consulte le détail (GET /vouchers/transaction-request/:token), entre son mot de passe kado, et confirme (POST /vouchers/confirm-pay). La validation est effectuée côté bénéficiaire — il est l'initiateur.

Modèle AModèle B (BCEAO)
Qui scanneCommerçant scanne QR bénéficiaireBénéficiaire scanne QR commerçant
InitiateurCommerçant (appelle /validate)Bénéficiaire (appelle /confirm-pay)
AuthentificationJWT MERCHANTJWT BENEFICIARY + mot de passe kado
TTL sessionN/A180s (token Redis)
Conformité BCEAOAcceptableRecommandé

Multi-PSP configurable

Architecture multi-prestataires de paiement — Bictorys, Wave Direct, Orange Money, extensible

Pourquoi ? Le marché ouest-africain évolue vite (nouveaux PSP, négociations commerciales, pannes). L'architecture multi-PSP permet de basculer entre prestataires sans toucher au code, et d'activer plusieurs PSP simultanément avec priorité de fallback.

Configuration centralisée

Les configurations PSP sont stockées dans la table PlatformSettings sous la clé payment_provider_{nom}. Chaque entrée contient :

ChampTypeDescription
enabledbooleanPSP actif ou non
environment'sandbox' | 'production'Environnement d'utilisation
usage'collect' | 'payout' | 'both'Usage encaissement ou reversement
prioritynumberOrdre de fallback (1 = principal)

Comparaison des PSP intégrés

PSPMéthodesFraisUsage
BictorysWave + OM + Cartes1% + 100 FCFA fixeCollect + Payout
Wave DirectWave uniquement1%Payout uniquement
Orange Money DirectOM uniquement1.5% à 2.5%Collect + Payout

Endpoints admin

MéthodeRouteDescription
GET/admin/settings/payment_provider_{name}Lire la config d'un PSP
PUT/admin/settings/payment_provider_{name}Mettre à jour la config (active/désactive)

Demande d'intégration nouveau PSP

L'admin peut demander l'intégration d'un nouveau PSP depuis l'interface (ex: PayDunya, CinetPay). La demande crée automatiquement un ticket support de catégorie TECHNICAL qui notifie l'équipe dev par email.

Sécurité Les clés API sensibles (BICTORYS_SECRET_KEY, WAVE_WEBHOOK_SECRET) restent dans les variables d'environnement Railway. L'interface admin ne stocke que les métadonnées de configuration.

Payouts groupés

Système de reversement par cron jobs (DAILY/WEEKLY/BIWEEKLY) — réduction des frais agrégateurs jusqu'à 95% vs INSTANT

Impact financier Pour 30 commerçants avec 5M FCFA de volume mensuel et frais de 1% + 100 FCFA fixe :
• INSTANT : 540 000 FCFA/mois de frais (auto à chaque transaction)
• WEEKLY : 18 000 FCFA/mois (-95%)
Économie annuelle : ~6,3 M FCFA

Enum PayoutSchedule

enum PayoutSchedule {
  INSTANT     // Au fil de l'eau — payout immédiat (option premium)
  DAILY       // Tous les jours à 09h Africa/Dakar
  WEEKLY      // Tous les lundis à 09h (défaut)
  BIWEEKLY    // 1er et 15 du mois à 09h
}

Cron jobs (timezone Africa/Dakar)

FréquenceCronService
DAILY0 9 * * *ScheduledPayoutsService.runDailyPayouts()
WEEKLY0 9 * * 1ScheduledPayoutsService.runWeeklyPayouts()
BIWEEKLY0 9 1,15 * *ScheduledPayoutsService.runBiweeklyPayouts()

Logique de calcul du solde dû

// Pour chaque commerçant ayant la fréquence cible :
totalDue = SUM(VoucherTransaction.netAmount WHERE merchantId = X)
totalPaid = SUM(MerchantSettlement.amount WHERE merchantId = X AND status = CONFIRMED)
balanceDue = MAX(0, totalDue - totalPaid)

// Si balanceDue > 0 : enqueue Bull job avec retry exponentiel
// Reference idempotente : SETTLE-{merchantId[:8]}-{timestamp}

Choix de la fréquence par commerçant

Chaque commerçant choisit sa propre fréquence depuis son dashboard POS via PATCH /merchants/me/payout-schedule. Le champ payoutSchedule est sur le modèle Merchant (défaut WEEKLY).

Simulateur admin

Page admin /admin/dashboard?tab=payouts-simulator permet de simuler en temps réel les 4 modèles selon :

  • Volume mensuel total
  • Nombre de commerçants
  • Commission kado (%)
  • Frais agrégateur (variable + fixe)
  • Nombre moyen de transactions par commerçant par jour

Le simulateur affiche pour chaque modèle : payouts/mois, frais totaux, marge nette, % marge conservée, et recommande automatiquement le modèle optimal.

Géolocalisation

Système de géolocalisation bénéficiaire et commerçant — recherche de proximité, itinéraires Google Maps

Backend — modèle Merchant

Le modèle Merchant a deux champs optionnels :

latitude   Float?
longitude  Float?

Endpoints

MéthodeRouteDescriptionAuth
GET/merchants/nearby?lat&lng&radiusRecherche commerçants proches (haversine)Public
PATCH/merchants/me/locationLe POS envoie sa position GPSMERCHANT
PATCH/admin/merchants/:id/locationAdmin met à jour la positionADMIN

Algorithme haversine

Calcul de distance entre deux coordonnées GPS (formule haversine). Approximation valide jusqu'à ~50 km avec une erreur < 0,5%.

// Approximation utilisée pour la recherche par rayon
deltaLat = radiusMeters / 111_000
deltaLng = radiusMeters / (111_000 * cos(lat * PI / 180))

// Bounding box puis vérification haversine exacte

Frontend bénéficiaire

  • Popup explicatif au premier login (localStorage geo_intro_seen)
  • Rayons configurables : 1 / 2 / 5 / 10 km (défaut 2 km)
  • Bouton "Itinéraire" → ouvre Google Maps avec navigation directe
  • Composant GeoHelpModal adaptatif iOS/Android/macOS/Desktop

Frontend commerçant POS

  • Capture manuelle de la position (bouton dans le dashboard)
  • Pas de popup automatique au login (UX moins intrusive)
  • Section permanente "Localisation du magasin"
Lien Google Maps Format : https://www.google.com/maps/dir/?api=1&destination=lat,lng&travelmode=driving. S'ouvre dans Google Maps si installé, sinon dans le navigateur.

Types de bons adaptés au Sénégal

11 catégories culturellement pertinentes — fin mai 2026

Liste finale

EnumLabelUsage
GIFT_VOUCHERCadeauCadeau classique
MEAL_TICKETTicket repasRestauration salariés
TRANSPORTTransportFrais déplacement
CEREMONIE_RELIGIEUSECérémonie religieuseKorité, Tabaski, Magal, Gamou
EVENEMENT_FAMILIALÉvénement familialBaptême, mariage, décès
SANTESanté / PharmacieSoins, médicaments
EDUCATIONÉducation / ScolaireFournitures, inscriptions
CARBURANTCarburantStations service
ALIMENTAIREAlimentaireCourses alimentaires
BIEN_ETREBien-êtreCoiffure, sport, soins
AIDE_SOCIALEAide socialeSolidarité, soutien employé

Valeurs dépréciées (rétrocompatibilité)

Les valeurs suivantes restent dans l'enum Prisma pour ne pas casser les bons existants en base, mais ne sont plus proposées à l'émission depuis le 23 mai 2026 :

  • BONUS — remplacé par AIDE_SOCIALE ou GIFT_VOUCHER selon le contexte
  • INDEMNITE — remplacé par AIDE_SOCIALE
  • RECOMPENSE — remplacé par GIFT_VOUCHER
  • BEAUTE — remplacé par BIEN_ETRE
Raison du retrait Les types Bonus/Indemnité/Récompense étaient trop sensibles fiscalement (proches du salaire imposable). Le retrait protège les entreprises clientes d'une mauvaise classification fiscale.

Support admin enrichi

Onglet support amélioré pour la gestion efficace des tickets — templates, appels directs, notifications email

Templates de réponses pré-rédigées

6 templates accessibles en 1 clic depuis la zone de réponse (mode "Envoyer au contact" uniquement) :

CléLabelCas d'usage
welcomeBienvenuePremier contact, accusé de réception
info_neededDemande infoCompléments requis pour traiter
resolvedRésoluConfirmation de résolution
follow_upSuiviVérification de satisfaction
escalationEscalade techTransmission équipe technique
closureClôtureFermeture sans réponse

Boutons d'appel direct

Trois boutons d'action rapide affichés dans la fiche ticket (si numéro/email renseignés) :

  • Appeler — protocole tel:
  • WhatsAppwa.me/{numéro nettoyé}
  • Emailmailto: avec sujet pré-rempli [{ref}] {sujet}

Notifications email automatiques

Chaque nouveau ticket (toutes sources confondues : merchant, company, beneficiary, internal) déclenche un email automatique vers SUPPORT_ADMIN_EMAIL (défaut support@kado.sn) avec template HTML pro :

  • Référence + sujet en en-tête violet kado
  • Badge priorité coloré (LOW/MEDIUM/HIGH/URGENT)
  • Source + catégorie traduites en français
  • Informations contact si renseignées
  • Description complète avec formatage préservé
  • Bouton "Ouvrir dans l'admin"

Lookup utilisateur intelligent

L'onglet "Accès & Comptes" permet de rechercher un utilisateur par téléphone ou email. La fonction normalizeIdentifier() supprime les espaces des numéros avant le lookup (ex: +221 77 358 88 28+221773588828) pour éviter les "Aucun utilisateur trouvé" injustifiés.

Reset password intelligent

Après réinitialisation, le backend renvoie { hasCompanyAccess, role }. Le frontend affiche conditionnellement :

  • Bénéficiaire pur (BENEFICIARY) → redirection auto vers /app/login
  • RH multi-rôles (COMPANY_ADMIN ou COMPANY_VIEWER avec companyId) → choix d'espace
Variable d'environnement SUPPORT_ADMIN_EMAIL dans Railway permet de personnaliser le destinataire des notifications. Si non défini, valeur par défaut : support@kado.sn.