diff --git a/CLAUDE.md b/CLAUDE.md index 560e9c2..7106c92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,8 @@ Voir `/docs/decisions.md` pour le log complet avec rationale. | `/landing/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon | | `/landing/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) | | `/landing/assets/logo.png` | Logo Rubis original (généré, source pour les favicons) | -| `/docs/produit.md` | Spec produit détaillée (features, flows, IN/OUT V1) | +| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing) | +| `/docs/flow.md` | **Comportement produit deep-dive** : cycle de vie d'une facture, statuts + transitions, surfaces UI, mécanique de confirmation (check-in), mode démo, edge cases | | `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) | | `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) | | `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP | diff --git a/docs/flow.md b/docs/flow.md new file mode 100644 index 0000000..521a51d --- /dev/null +++ b/docs/flow.md @@ -0,0 +1,418 @@ +# Flow produit — Rubis Sur l'Ongle + +> Cette doc décrit **comment Rubis se comporte** côté user-lambda : quel +> statut une facture prend, quand elle change d'état, qui voit quoi quand. +> Pour la spec produit haut-niveau (cible, pricing, IN/OUT V1) → `produit.md`. +> Pour les décisions architecturales → `decisions.md`. + +--- + +## 1. Modèle mental en une phrase + +> Une facture passe par un nombre limité d'**états bien définis**, et toute +> transition est **soit déclenchée par l'utilisateur** (mark paid, "non +> impayée"), **soit par le scheduler** (envoi d'un mail, fin de plan). Aucune +> transition silencieuse n'envoie de mail au client final sans validation +> humaine — c'est la promesse centrale de la marque. + +--- + +## 2. Glossaire produit + +| Terme | Définition | +|---|---| +| **Rubis** | Unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance que l'user n'a pas eu à faire à la main. Crédité sur l'org à chaque mark-paid. | +| **Plan de relance** | Cadence d'emails programmés (ex. J+3, J+10, J+25) avec un ton et un contenu par étape. Une facture est associée à 0 ou 1 plan. | +| **Étape** (`PlanStep`) | Un email programmé dans un plan. Possède : un `offsetDays` (relatif à la `dueDate`), un ton, un sujet, un body, et un flag `requiresManualValidation` (pour la mise en demeure). | +| **Confirmation** | (Anciennement "check-in"). Mécanique cœur du produit : Rubis demande à l'user "cette facture a-t-elle été payée ?" avant d'envoyer la prochaine relance. Voir §5. | +| **Mise en demeure** | Étape ferme du plan, avec mention LME explicite. **Toujours sous validation manuelle** via modale de confirmation, jamais auto. | +| **DSO** | Days Sales Outstanding — délai moyen entre l'émission d'une facture et son paiement. Métrique secondaire. | +| **LME** | Loi de Modernisation de l'Économie (2008). Plafonne les délais de paiement à 60j (ou 45j fin de mois). Encadre la rédaction des mises en demeure. | + +--- + +## 3. Cycle de vie d'une facture + +### 3.1 Les 6 états + +Une facture est toujours dans **exactement un** des états suivants (`Invoice.status`) : + +| État | Sens produit | Visible dans le filtre | +|---|---|---| +| `pending` | Facture **fraîchement importée**, pas encore relancée. Attente du premier check-in (qui partira à `dueDate`). | "À relancer" | +| `awaiting_user_confirmation` | Le check-in a été envoyé à l'user, on attend qu'il réponde "payée ?" ou "toujours impayée ?". Le scheduler ne fait rien tant que l'user n'a pas répondu. | "À valider" | +| `in_relance` | L'user a confirmé l'impayé → le plan tourne. Au moins une relance a été ou est sur le point d'être envoyée au client. | "En relance" | +| `paid` | Facture réglée. `paidAt` populé, `+1 rubis` crédité, futures relances `cancelled`. | "Encaissées" | +| `litigation` | Facture contestée par le client (refus, désaccord, longue impayé). Pas de relance auto, action manuelle requise (huissier, recommandé, médiation). | "Litige" | +| `cancelled` | Facture annulée (avoir, doublon, erreur). Sortie du portefeuille actif, conservée en historique. | (pas dans les chips, accessible "Toutes") | + +### 3.2 Diagramme de transitions + +``` + ┌─────────────────────────┐ + │ Import / saisie │ + │ manuelle │ + └────────────┬────────────┘ + ↓ + ┌────────┐ + │ pending │ + └───┬─────┘ + │ + Le scheduler envoie le check-in + à dueDate (= jour de l'échéance) + ↓ + ┌─────────────────────────────────────────┐ + │ awaiting_user_confirmation │ ←──── état pivot + └────┬────────────────┬────────────────┬──┘ (UI insiste ici : + │ │ │ modale au login, + Réponse "Oui"│ │ Réponse │ bouton fiche + (payée) │ │ "Non" │ facture) + ↓ ↓ ↓ + ┌────────┐ ┌────────────┐ (dismiss → reste + │ paid │ │ in_relance │ en awaiting jusqu'à + └────────┘ └─────┬──────┘ reprise par l'user) + │ + │ scheduler envoie chaque + │ étape à `dueDate + offset` + │ + │ user clique "Marquer encaissée" + │ depuis la fiche facture ↓ + ↓ + ┌────────┐ + │ paid │ + └────────┘ + +Transitions manuelles (par l'user, depuis la fiche facture) : + • [tout état actif] → litigation (TODO V1.5 — UI à câbler) + • [tout état] → cancelled (TODO V1.5 — UI à câbler) + • paid → (pas de retour, sauf bug — il y a un endpoint mark-unpaid V2 prévu) +``` + +### 3.3 Détails par transition + +#### `pending → awaiting_user_confirmation` +- **Qui déclenche** : le scheduler `CheckinTask`, automatiquement, quand `dueDate` est atteinte. +- **Effet** : un email check-in part à l'user (pas au client) avec 2 liens *Oui (payée)* / *Non (toujours impayée)*. La `CheckinTask` est marquée `sent`. +- **Note V1** : dans la prod actuelle, le statut DB de l'invoice reste techniquement `pending` jusqu'à ce que l'user réponde — c'est le seed démo qui force `awaiting_user_confirmation` pour pré-peupler des cas. À aligner V1.5 (le job `send_checkin_job` devrait push le statut). + +#### `awaiting_user_confirmation → paid` (réponse "Oui") +- **Qui déclenche** : l'user, via 4 surfaces possibles : + 1. Modale in-app au login (`InAppCheckinModal`) — bouton **Oui — la facture est payée** + 2. Lien `paid` dans l'email check-in + 3. Fiche facture — bouton **Marquer encaissée** + 4. Slide-over en mode démo (`DemoEmailSlide`) +- **Effet en cascade** : + - `Invoice.status = 'paid'`, `Invoice.paidAt = now` + - `Invoice.rubisEarned += 1`, `Organization.rubisCount += 1` + - Toutes les `RelanceTask` `scheduled` futures → `cancelled` (jobs BullMQ removed) + - `CheckinTask.status = 'answered'`, `answer = 'paid'`, `answeredAt = now` + - Activity event `invoice_paid` + +#### `awaiting_user_confirmation → in_relance` (réponse "Non") +- **Qui déclenche** : l'user, via les mêmes 4 surfaces. +- **Effet** : + - `Invoice.status = 'in_relance'` + - `CheckinTask.status = 'answered'`, `answer = 'still_pending'` + - `scheduleRelancesForInvoice()` est appelé : + - Récupère le plan associé à la facture + - Pour chaque step : crée une `RelanceTask` avec `sendAt = invoice.dueDate + step.offsetDays` + - Si certains `sendAt` sont déjà passés (cas d'une facture en retard), la 1re étape éligible est calée à `now + 1min` puis les suivantes gardent l'écart prévu (cf. `catchUpAnchor` dans `relance_scheduler.ts`) + - Enqueue chaque task dans BullMQ avec `delay = sendAt - now` + - Activity event `relance_sent` + +#### `in_relance → paid` (mark paid manuel) +- **Qui déclenche** : l'user via le bouton **Marquer encaissée** sur la fiche facture (typiquement après avoir reçu un virement). +- **Effet** : identique au "Oui" depuis check-in (mark paid + cancel relances + bonus rubis). + +#### `in_relance → litigation` (V1.5) +- Pas encore câblé en UI. Endpoint backend prévu : `POST /invoices/:id/litigate` qui : + - Status → `litigation` + - Cancel les relances futures + - (Optionnel) génère un brouillon de mise en demeure si pas déjà envoyé + +#### `* → cancelled` (V1.5) +- Toute facture sortie du flux normal (annulée, doublon). +- Pas de comptage dans le CA, pas dans les graphes. + +--- + +## 4. Surfaces UI — où l'user agit + +### 4.1 Modale check-in au login (`InAppCheckinModal`) + +**Quand** : à chaque ouverture de l'app si `pending` non vide (factures `awaiting_user_confirmation`). + +**Comportement** : +- Affiche **toujours `queue[0]`** — l'invoice la plus ancienne en attente +- L'user a 4 options : + - **Oui** → mark paid (cf. transition ci-dessus) + - **Non** → schedule relances + status → in_relance + - **Plus tard** → skip cette facture pour la session (set local en mémoire), passe à `queue[1]` + - **X (close)** → ferme la modale **pour ce moment seulement** +- Si l'user ferme et revient sur l'onglet (`focus` / `visibilitychange`), `dismissed` est reset à `false` → la modale re-pop si pending est encore non-vide. TanStack Query refetch sur focus en bonus. +- Pas de persistance `sessionStorage` : le user qui dismiss accède toujours aux factures via `/factures?status=awaiting_user_confirmation` (chip "À valider"). + +**Mobile vs desktop** : +- Mobile : bottom-sheet (slide-from-bottom, drag handle, safe-area-inset-bottom) +- Desktop : modale centrée, max 520px + +### 4.2 Email check-in + +**Quand** : envoyé automatiquement par le scheduler à `dueDate` de chaque facture (sauf si déjà payée ou cancelled). + +**Contenu** : +- À : l'utilisateur Rubis (**pas le client final**) +- Reply-to : l'utilisateur lui-même (au cas où il répondrait) +- Sujet : "Facture F-XXXX — payée par CLIENT_NAME ?" +- Body avec 2 liens : + - `https://app.rubis.../api/v1/checkin/:token/paid` → respondPaid → redirect SPA `?checkin=paid` + - `https://app.rubis.../api/v1/checkin/:token/pending` → respondPending → redirect SPA `?checkin=pending` +- Token signé (32 bytes random base64url, hash SHA-256 stocké en DB), TTL 24h. + +**Sécurité** : +- Si le token est invalide / inconnu / expiré → redirect SPA `?checkin=invalid|expired` +- Si l'invoice a été payée entre-temps → `?checkin=already_answered` +- Pas de réponse possible 2× (idempotent côté serveur). + +### 4.3 Fiche facture (`/factures/:id`) + +Header avec 2 boutons d'action quand status ∈ {`pending`, `awaiting_user_confirmation`, `in_relance`} : + +| Bouton | Visible si | Effet | +|---|---|---| +| **Marquer encaissée** | tout sauf paid/cancelled/litigation | Mark paid (transition vers `paid`) | +| **Relancer maintenant** | `pending` ou `awaiting_user_confirmation` | Lance les relances (transition vers `in_relance`) | + +Le 2e bouton est désactivé pour `in_relance` (le plan tourne déjà, pas de re-trigger possible côté UI V1). + +Sur la timeline de la fiche, on affiche tous les events : +- `invoice_imported` (toujours, à `issueDate`) +- `relance_sent` (1 par relance déjà envoyée) +- `invoice_paid` (si payée) + +Et toutes les `RelanceTask` du plan, avec leur statut visuel : +- **Envoyée après votre confirmation** (`task.status = 'sent'`) +- **Confirmation avant envoi** (`task.status = 'scheduled'`) — wording uniforme, rappelle qu'aucun mail ne part sans validation +- **Annulée — facture encaissée** (`task.status = 'cancelled'`) + +Au-dessus de la timeline : un bandeau rassurant *"Aucune relance ne part sans votre validation. Avant chaque envoi, Rubis vous demande si la facture a été réglée — vous gardez la main."* + +### 4.4 Slide-over démo (`DemoEmailSlide`) + +**Quand** : en mode démo, à chaque event qui fire (relance ou check-in dû). L'horloge virtuelle est en pause tant que l'user n'a pas acquitté. + +**Comportement** : +- Étape 1 ("ask") : même question qu'en réel, mêmes 2 boutons Oui/Non +- "Oui" → mark paid + écran "Encaissée" +- "Non" → écran preview de l'email qui aurait été envoyé (pas envoyé pour de vrai en démo, capturé en BDD via `demo_captured_emails`) +- "Continuer la démo" → reprend l'horloge + +Mobile : bottom-sheet (idem modale check-in). +Desktop : slide-over droit (h-screen, max 520px). + +--- + +## 5. Le mécanisme de confirmation (deep dive) + +### 5.1 Pourquoi cette mécanique + +C'est la **promesse centrale** de Rubis : aucune relance ne part sans que l'user ait dit "oui, vraie impayée, lance". + +Sans ça, le SaaS ferait peur (peur de relancer un client qui vient de payer). Avec ça, le user contrôle, et Rubis automatise tout le reste. + +### 5.2 Architecture des `CheckinTask` + +- Un `CheckinTask` est créé à la création de l'invoice (sauf si `pending` future) — programmé pour `dueDate`. +- Au moment où le job tourne (queue `checkins`), il envoie l'email à l'user, marque la task `sent`, mais **ne change PAS le statut de l'invoice côté prod** (TODO V1.5 — bascule en `awaiting_user_confirmation` quand l'email est envoyé). +- L'user a 24h (TTL) pour cliquer un des 2 liens email. Au-delà, la task expire (status `expired`) — elle ne refire pas, mais l'user peut toujours répondre via la modale in-app ou la fiche. + +### 5.3 Architecture des `RelanceTask` + +- Créées **uniquement** quand l'user répond "Non" au check-in (ou clique "Relancer maintenant"). Pas avant. +- Une `RelanceTask` correspond à un `PlanStep` × une `Invoice`. Status : + - `scheduled` : en attente de fire + - `sent` : email envoyé OK + - `cancelled` : annulée (facture mark paid, statut litigation, etc.) + - `failed` : échec d'envoi définitif (5 retry exponentiels) +- Le job BullMQ `relance-{taskId}` fire à `task.sendAt`. Idempotent : si la task n'est plus `scheduled`, no-op. + +### 5.4 Les 4 surfaces qui peuvent répondre au check-in + +Toutes appellent in fine la même logique métier (mêmes mutations DB, mêmes effets) : + +| Surface | Endpoint | Auth | +|---|---|---| +| Email lien `paid` | `GET /api/v1/checkin/:token/paid` | Token URL | +| Email lien `pending` | `GET /api/v1/checkin/:token/pending` | Token URL | +| Modale in-app — Oui | `POST /api/v1/checkin/inapp/:invoiceId/paid` | Bearer auth | +| Modale in-app — Non | `POST /api/v1/checkin/inapp/:invoiceId/pending` | Bearer auth | +| Fiche facture — Marquer encaissée | `POST /api/v1/invoices/:id/mark-paid` | Bearer auth | +| Fiche facture — Relancer maintenant | `POST /api/v1/checkin/inapp/:invoiceId/pending` | Bearer auth | +| Démo — Oui | `POST /api/v1/invoices/:id/mark-paid` | Bearer auth | + +--- + +## 6. Plans de relance + +### 6.1 Structure + +``` +Plan +├── name: "Standard B2B" +├── slug: "standard-b2b" +├── description: "Plan équilibré pour la majorité des factures" +└── steps[] + ├── PlanStep { offsetDays: 3, tone: "amical", subject, body, requiresManualValidation: false } + ├── PlanStep { offsetDays: 10, tone: "ferme", subject, body, requiresManualValidation: false } + └── PlanStep { offsetDays: 25, tone: "stricte", subject, body, requiresManualValidation: true } + ↑ + mise en demeure → bouton manuel, + jamais d'envoi auto +``` + +### 6.2 Plans pré-fournis + +| Plan | Cadence | Cible | +|---|---|---| +| **Standard B2B** | J+3, J+10, J+25 (mise en demeure brouillon) | Majorité des factures | +| **Rapide** | J+1, J+5, J+10 — ton ferme | Cash flow tendu | +| **Patient** | J+15, J+30, J+60 — ton doux | Clients VIP, partenaires | +| **Ferme** | J+0, J+5, J+10, J+15 — ton ferme dès le départ | Grosses factures, clients à risque | + +### 6.3 Création custom + +Wizard 4 étapes (`/plans/nouveau`) : +1. **Identité** : nom, description, slug auto +2. **Cadence** : éditeur calendrier visuel pour placer les J+X +3. **Messages** : pour chaque étape, choisir ton + écrire/IA-générer le contenu (avec variables `{{client.name}}`, `{{numero}}`, etc.) +4. **Récap** : preview avant création + +### 6.4 Variables dans les templates + +Disponibles dans `subject` et `body` des steps : +- `{{client.name}}`, `{{client.email}}`, `{{client.contactFirstName}}`, `{{client.contactLastName}}` +- `{{user.fullName}}`, `{{user.companyName}}` +- `{{numero}}`, `{{amount}}`, `{{dueDate}}`, `{{issueDate}}` +- `{{daysLate}}` (jours de retard arrondis, démo-aware via `clock.now()`) +- `{{signature}}` (de l'user, posée en /parametres) + +Render via une fonction de substitution simple (pas Mustache section, pas de logique conditionnelle). + +--- + +## 7. Mode démo + +### 7.1 Flag d'activation + +`Organization.demoMode = true` (toggle dans `/parametres`). Tant que ce flag est false, **rien** dans l'app ne dévie de la prod. + +### 7.2 Effets côté prod + +**Une seule** branche dans le code prod, dans `mail_dispatcher.ts` → `captureEmailIfDemo()` : +- Si `org.demoMode = true` → l'email n'est PAS envoyé via SMTP/Resend, capturé en BDD (`demo_captured_emails`) +- Sinon → envoi normal + +Tout le reste (idempotence, status update, rubis bump, BullMQ, scheduling) tourne identique à la prod. + +### 7.3 Horloge virtuelle + +`Organization.virtualNow` (timestamp). En mode démo, **toutes** les fonctions qui lisent l'heure passent par `clock.now(orgId)` qui : +- En prod (demoMode=false) → `DateTime.utc()` +- En démo → `org.virtualNow` (cache 250ms pour éviter le spam DB) + +Conséquence : on peut faire avancer l'horloge à 1x/2x/5x via le hook `useDemoTick` côté SPA (rAF loop + sync backend toutes les 250ms). À chaque event qui fire (relance dûe, check-in dû), l'horloge se met en pause et le slide-over s'ouvre. + +### 7.4 Sortir du mode démo + +`/demo/end` — l'org repasse en demoMode=false. La boîte capturée reste consultable (on ne wipe pas). + +--- + +## 8. KPIs et calculs + +### 8.1 Compteur rubis + +- Crédité +1 à chaque `invoice_paid` (mark paid). Sur l'invoice (`rubisEarned`) ET sur l'org (`rubisCount`). +- "Rubis ce mois" = somme des `rubisEarned` des factures payées avec `paidAt >= startOfMonth(now)`. +- Conversion **1 rubis = 10 minutes libérées** affichée partout (ex: 18 rubis ≈ 3h libérées). + +### 8.2 Encaissé + +Somme des `amountTtcCents` des factures `status = 'paid'` : +- Sur le mois (KPI dashboard) +- Sur N derniers mois (chart Insights, bucket mensuel ou hebdo selon range) +- Delta vs mois précédent (KPI dashboard) + +### 8.3 DSO (Days Sales Outstanding) + +Pour chaque facture payée : `daysToPayment = paidAt - issueDate`. +DSO du mois = moyenne arithmétique sur les factures `paid_at >= startOfMonth(now)`. + +### 8.4 Pipeline (chart Insights) + +Donut + légende avec count + montant TTC par statut : +- pending, awaiting_user_confirmation, in_relance, litigation, paid +- `cancelled` exclus (bruit). + +--- + +## 9. Edge cases & règles à connaître + +### 9.1 Plan changé sur facture en cours + +Si l'user change le plan d'une facture qui est `in_relance` : +- Les `RelanceTask` `scheduled` du précédent plan sont `cancelled` (jobs BullMQ removed). +- Les nouvelles tasks sont créées selon le nouveau plan. +- Les tasks `sent` restent intactes (historique). + +### 9.2 Facture marquée payée pendant qu'une relance est en queue + +Le worker BullMQ check `invoice.status === 'paid'` au moment de fire : +- Si oui → cancel la task, no-op. +- Sinon → envoi normal. + +C'est ce qui évite l'envoi d'une relance après mark paid. + +### 9.3 Idempotence du check-in + +Si l'user clique 2× le lien `/checkin/:token/paid` : +- 1er clic : `task.status = 'answered'`, redirect SPA avec succès. +- 2e clic : task déjà answered → redirect `?checkin=already_answered` (toast "Cette confirmation avait déjà été traitée"). + +### 9.4 Re-pop modale au refocus + +L'user qui dismiss la modale puis bascule sur Slack et revient → modale re-pop si pending non vide (cf. §4.1). Pas de spam si l'user reste sur l'onglet. + +### 9.5 Mode démo et signature + +L'`User.signature` (posée en /parametres) est interpolée dans tous les templates `{{signature}}`. En démo, mêmes signatures que prod — la capture montre exactement ce qui partirait en réel. + +--- + +## 10. Métriques produit à instrumenter + +(reprise + précisions vs `produit.md` §9) + +- **Taux de réponse au check-in** : % des CheckinTask `answered` vs total `sent`. Cible : >70%. Si bas → l'email check-in n'est pas lu, à itérer (subject, timing). +- **Latence de réponse** : médiane du `answeredAt - sentAt`. Cible : <24h. +- **Ratio Oui/Non au check-in** : si trop de "Oui" → on relance des factures déjà payées hors plateforme (signal pour pousser banking V2). Si trop de "Non" → cadence cohérente. +- **Conversion `pending` → `paid` sans relance** : combien de factures sont marquées payées avant la 1re relance. Indicateur de bonne hygiène utilisateur. +- **DSO moyen pré/post Rubis** : à mesurer via export historique vs progression mensuelle. +- **% factures qui passent en `litigation`** : doit rester <2%. Au-delà, c'est que les plans sont trop agressifs. + +--- + +## 11. Ce que Rubis ne fait PAS (rappel) + +| Hors-scope | Pourquoi | +|---|---| +| Émettre des factures | On n'est pas un Henrri-bis. On relance ce qui sort d'ailleurs. | +| Réconciliation banking auto | V2+. V1 = check-in email. | +| Relancer par SMS | V2 (réservé plan le plus cher). | +| Multi-utilisateurs | V2 (plans payants seulement). | +| CRM / pipeline commercial | On reste pure-player relance. | +| Recouvrement contentieux | Hors-scope définitif. La mise en demeure est le seuil. Au-delà, c'est huissier. | + +--- + +*Dernière maj : 2026-05-07. Maintenu par Arthur + Claude.*