Documente la feature ajoutée en V1.1 dans toute la doc cadre : - **CLAUDE.md** : "Pure-player relance" nuancé en "La relance reste l'âme du produit", extension douce assumée. Périmètre V1/IN enrichi avec l'éditeur de factures. Glossaire enrichi (facture native, numéro de séquence, snapshot, Factur-X). Stack : ajout @react-pdf/renderer + pointeurs vers pdf-templates et les routes /parametres/facturation et /factures/nouvelle. - **docs/produit.md** : nouvelle section 4.2bis "Édition native des factures" — scope V1.1 minimal, snapshots immuables, numérotation strict séquentielle, roadmap Factur-X V1.5 / PDP V2. - **docs/flow.md** : nouvelle section 11bis (3 sources d'une facture, flow utilisateur de création, génération PDF, numérotation, snapshots, cas limites). Tableau "Ce que Rubis ne fait PAS" mis à jour (édition oui mais pas devis/avoirs/Factur-X V1). - **docs/decisions.md** : ADR-025 "Édition native des factures + roadmap Factur-X" (rationale extension douce, choix techniques notables, alternatives écartées). - **docs/tech/architecture.md** : section 6.1bis (flow technique édition native, points d'attention numérotation atomique + lazy PDF regenerate), ajout @react-pdf à la stack, routes /native + /preview-pdf + /invoice-themes + /invoice-settings documentées. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
572 lines
32 KiB
Markdown
572 lines
32 KiB
Markdown
# 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`
|
||
- **L'`Invoice.status` passe de `pending` à `awaiting_user_confirmation`** (uniquement si encore en `pending` — pas de bump si la facture a été marquée payée ou autre entre temps)
|
||
- Côté SPA : la modale check-in `usePendingCheckins` voit désormais cette facture et l'affiche au prochain login / refocus
|
||
|
||
#### `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 :
|
||
1. Envoie l'email à l'user
|
||
2. Marque la `CheckinTask.status = 'sent'`
|
||
3. **Bump l'`Invoice.status` en `awaiting_user_confirmation`** si encore `pending` (la modale in-app la voit alors)
|
||
- 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 (le statut reste `awaiting_user_confirmation` tant qu'il n'a pas répondu).
|
||
|
||
### 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-30j"
|
||
├── description: "Cadence sobre, ton qui monte progressivement."
|
||
└── steps[]
|
||
├── PlanStep { offsetDays: 3, tone: "amical", subject, body, requiresManualValidation: false }
|
||
├── PlanStep { offsetDays: 10, tone: "courtois", subject, body, requiresManualValidation: false }
|
||
└── PlanStep { offsetDays: 25, tone: "ferme", subject, body, requiresManualValidation: true }
|
||
↑
|
||
mise en demeure → bouton manuel,
|
||
jamais d'envoi auto
|
||
|
||
Tons disponibles : `amical | courtois | ferme | mise_en_demeure`
|
||
(cf. `apps/api/app/services/default_plans.ts:18`).
|
||
```
|
||
|
||
### 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. Pricing & enforcement
|
||
|
||
### 11.1 Plans
|
||
|
||
| Plan | Prix mensuel | Prix annuel | Limite factures actives | Sièges | V2 |
|
||
|---|---|---|---|---|---|
|
||
| **Free** | 0 € | — | 5 (après période de grâce 3 mois) | 1 | — |
|
||
| **Pro** | 19 € | 190 € (-17%) | illimitées | 1 | — |
|
||
| **Business** | 49 € | 490 € (-17%) | illimitées | 5 (V2 multi-users) | reply-from-user-email, SMS |
|
||
|
||
"Facture active" = statut ∈ {`pending`, `awaiting_user_confirmation`, `in_relance`, `litigation`}. Les `paid` et `cancelled` ne consomment pas de slot.
|
||
|
||
### 11.2 Période de grâce 3 mois
|
||
|
||
À la création de l'org : `gracePeriodEndsAt = createdAt + 3 mois`. Pendant cette fenêtre, le plan Free est **illimité** (l'user teste full power). Au-delà :
|
||
- Si `activeInvoicesCount ≤ 5` → reste Free, fonctionne normalement
|
||
- Si `activeInvoicesCount > 5` → import bloqué (HTTP 402 `plan_limit_reached`) jusqu'à upgrade
|
||
|
||
L'API qui enforce : `canCreateInvoices(organizationId, delta)` dans `app/services/billing.ts`. Appelée par :
|
||
- `POST /invoices/import-batch/:id/drafts/:draftId/validate` (validation OCR)
|
||
- `POST /invoices` (saisie manuelle)
|
||
|
||
Les uploads de PDFs ne sont PAS bloqués (le user peut empiler des drafts), seule la **validation** qui crée l'`Invoice` finale check la limite.
|
||
|
||
### 11.3 Stripe — flow technique
|
||
|
||
**Setup initial** (1 fois par compte Stripe — test ou prod) :
|
||
```bash
|
||
node ace stripe:setup
|
||
```
|
||
Crée 2 Products (Pro, Business) + 4 Prices (mensuel + annuel pour chacun) avec `lookup_key` stable (`rubis_pro_monthly`, `rubis_pro_yearly`, `rubis_business_monthly`, `rubis_business_yearly`). Idempotent.
|
||
|
||
**Flow utilisateur — upgrade Free → Pro** :
|
||
1. Click "Passer Pro" sur `/parametres/abonnement`
|
||
2. SPA appelle `POST /api/v1/billing/checkout { plan, cycle }`
|
||
3. Backend crée un Stripe Customer (si pas déjà) + une Stripe Checkout Session, retourne `{ url }`
|
||
4. SPA redirect vers Stripe (UI hostée, 3DS géré)
|
||
5. User paye → Stripe redirect `success_url = /parametres/abonnement?checkout=success`
|
||
6. **En parallèle** : Stripe envoie `checkout.session.completed` au webhook → org passe en plan Pro
|
||
|
||
**Webhook** (`POST /api/v1/billing/webhook`, public, signature vérifiée) :
|
||
- `checkout.session.completed` → premier paiement OK, set plan + subscriptionId
|
||
- `customer.subscription.updated` → renouvellement / change de plan / mise à jour status
|
||
- `customer.subscription.deleted` → annulation effective → fallback `free`
|
||
- `invoice.payment_failed` → status `past_due` (UI rappelle l'user)
|
||
|
||
Le webhook est **idempotent** : Stripe peut re-livrer plusieurs fois le même event, on traite chaque fois en read-then-write sans assumer 1-shot.
|
||
|
||
**Customer Portal** (gestion CB / annulation) :
|
||
1. Click "Gérer" sur `/parametres/abonnement`
|
||
2. SPA appelle `POST /api/v1/billing/portal`
|
||
3. Backend crée une Stripe Billing Portal Session, retourne `{ url }`
|
||
4. SPA redirect vers Stripe (CB, factures, annulation, tout est géré là)
|
||
|
||
### 11.4 Champs DB (`organizations`)
|
||
|
||
| Colonne | Type | Sens |
|
||
|---|---|---|
|
||
| `plan` | `'free' \| 'pro' \| 'business'` | Plan courant. Default `free`. |
|
||
| `grace_period_ends_at` | timestamp | `created_at + 3 mois`. NULL après upgrade. |
|
||
| `stripe_customer_id` | string | Set au 1er checkout, jamais réécrit. |
|
||
| `stripe_subscription_id` | string | Refresh à chaque webhook subscription. |
|
||
| `subscription_status` | string | `active`, `trialing`, `past_due`, `canceled`, `incomplete`, `unpaid` (mirroring Stripe). |
|
||
| `billing_cycle` | `'monthly' \| 'yearly'` | Pour l'UI. |
|
||
| `current_period_end` | timestamp | "Prochaine facture le X". |
|
||
|
||
### 11.5 UI surfaces billing
|
||
|
||
- `/parametres/abonnement` — comparaison Free/Pro/Business + toggle mensuel/annuel + plan courant + bouton portail
|
||
- `/parametres` (la page principale) — section "Abonnement" qui montre le plan courant + lien "Gérer l'abonnement"
|
||
- `/factures` — `<PlanLimitBanner>` :
|
||
- Pendant grâce : rappel discret "illimité jusqu'au DD/MM/YYYY"
|
||
- Approche limite (ratio ≥ 80%) : avertissement avant blocage
|
||
- Limite atteinte : banner rouge bloquant + CTA "Passer Pro"
|
||
- Toast 402 sur la validation OCR : message + bouton action "Passer Pro" qui navigue vers /parametres/abonnement
|
||
|
||
### 11.6 Variables d'environnement
|
||
|
||
```
|
||
STRIPE_SECRET_KEY=sk_test_... (ou sk_live_... en prod)
|
||
STRIPE_WEBHOOK_SECRET=whsec_... (signature du endpoint)
|
||
```
|
||
|
||
En dev local, exposer le webhook via `stripe listen --forward-to localhost:3333/api/v1/billing/webhook` (Stripe CLI requise) — la CLI affiche le `whsec_...` à mettre en env.
|
||
|
||
---
|
||
|
||
## 11bis. Édition native des factures (V1.1)
|
||
|
||
> Pour le rationale, voir ADR-025. Ici on documente le flow.
|
||
|
||
### 11bis.1 Trois sources d'une facture dans Rubis
|
||
|
||
| Source | Origine | Drapeau DB | PDF | Snapshots |
|
||
|---|---|---|---|---|
|
||
| **OCR** | Upload drag-and-drop, extraction Mindee/Document AI | `is_native = false` | Fichier source uploadé (`pdf_storage_key`) | aucun |
|
||
| **Saisie manuelle** | `ManualInvoiceDialog`, 6 champs | `is_native = false` | `pdf_storage_key = null` (pas de fichier) | aucun |
|
||
| **Native (V1.1)** | Éditeur `/factures/nouvelle` | `is_native = true` | Généré côté serveur, stocké MinIO | `client_snapshot` + `issuer_snapshot` figés |
|
||
|
||
Les statuts (`pending`, `in_relance`, `paid`…) et le cycle de vie (cf. §3) sont **identiques** pour les 3 sources. La distinction est UX / présentation : une facture native peut être ré-éditée (avec re-génération PDF) tant qu'elle n'est pas dans un relance déclenchée ; une OCR ne peut pas.
|
||
|
||
### 11bis.2 Création (flow utilisateur)
|
||
|
||
1. `/factures` → clic "Créer une facture" (bouton primaire à côté de "Importer").
|
||
2. `/factures/nouvelle` : split-view. À gauche, formulaire ; à droite, iframe PDF live (debounce 500 ms via `POST /api/v1/invoices/preview-pdf`).
|
||
3. L'utilisateur saisit : client (combobox autocomplete), dates (émission + délai → échéance calculée), plan de relance (optionnel), thème + accent, lignes (désignation/qté/PU/TVA), notes pied de page.
|
||
4. Le serveur recalcule HT/TVA/TTC à chaque preview (jamais confiance au client). Le front affiche aussi un total live en local pour feedback instantané (mêmes règles d'arrondi : `Math.round` par ligne).
|
||
5. Deux boutons en footer :
|
||
- **Enregistrer en brouillon** → `POST /invoices/native` avec `draft: true`. Statut `pending`, `sequence_number = null`, `numero = "BROUILLON-XXXX"`. Pas de check-in programmé.
|
||
- **Émettre la facture** → `POST /invoices/native` avec `draft: false`. Alloue le prochain numéro de la séquence (`sequence_number = N`, `numero = "<prefix>0042"`) en transaction avec verrou `FOR UPDATE` sur la ligne `organizations`. Programme le check-in si un plan est associé.
|
||
|
||
### 11bis.3 Génération PDF
|
||
|
||
- Templates dans `apps/api/app/pdf-templates/` (4 fichiers TSX + `common.tsx` + `index.tsx` dispatcher).
|
||
- Pipeline : `renderInvoiceToBuffer(themeSlug, props)` → `@react-pdf/renderer.renderToBuffer` → `uploadBuffer(buf, 'invoice-pdf', orgId)` → `invoices/<orgId>/<uuid>.pdf` sur MinIO.
|
||
- Snapshot `client_snapshot` lu en priorité, fallback sur le client live pour la preview (qui n'a pas encore figé les snapshots).
|
||
- Lazy regenerate : si `GET /invoices/:id/pdf` reçoit une facture native sans `pdf_storage_key` (génération échouée au store), on retente à la volée et on persiste.
|
||
|
||
### 11bis.4 Numérotation (point sensible légalement)
|
||
|
||
- Compteur dans `organizations.invoice_settings.numeroNextSeq` (JSONB).
|
||
- Allocation : `SELECT invoice_settings FROM organizations WHERE id = $1 FOR UPDATE` → lit le compteur, increment, écrit `invoice_settings = jsonb_set(...)`. Garantit l'unicité même sous concurrence (deux onglets, deux jobs).
|
||
- Brouillons : `numero` éphémère type `BROUILLON-A1B2C3D4`, `sequence_number = null`. Aucun risque de gap si l'utilisateur supprime un brouillon (la séquence n'a pas été allouée).
|
||
- Unicité : `UNIQUE (organization_id, numero)` + `UNIQUE (organization_id, sequence_number)` (partial, autorise plusieurs `NULL`).
|
||
- Override initial : l'utilisateur peut définir `numeroNextSeq = 42` dans les settings une fois (pour reprendre une séquence existante d'un autre outil). Au-delà, c'est auto-incrémenté.
|
||
|
||
### 11bis.5 Snapshots (immutabilité)
|
||
|
||
À l'émission d'une facture native, deux JSONB sont gelés :
|
||
- `client_snapshot` : nom, email, contact, SIREN/SIRET, TVA intra, adresse structurée du client tel qu'il était.
|
||
- `issuer_snapshot` : identité émetteur tel qu'elle était dans `invoice_settings.issuer`.
|
||
|
||
Si plus tard le client déménage ou si l'org modifie son SIRET, **les factures déjà émises restent identiques** au PDF stocké. Cf. ADR-025 pour le rationale (preuve comptable).
|
||
|
||
### 11bis.6 Cas limites
|
||
|
||
- **Plan limite Free atteint** (5 factures actives) : `POST /invoices/native` renvoie 402 `plan_limit_reached` — même règle que la saisie OCR/manuelle.
|
||
- **Lignes vides** : le SPA bloque le submit si une ligne n'a pas de description ou si `unitPriceCents < 0`.
|
||
- **Génération PDF échoue post-store** : on log, on continue (la facture est créée), `pdf_storage_key = null`. Le prochain `GET /:id/pdf` regénère lazy.
|
||
- **Aperçu live et client inexistant** : le SPA ne POST pas tant qu'on n'a pas de `clientId` (combobox doit avoir sélectionné une fiche).
|
||
- **Race condition sur la séquence** : verrou row-level sur `organizations` → sérialise les `storeNative` concurrents pour la même org. Pas de gaps possibles hors brouillons supprimés (et les brouillons ne consomment pas la séquence).
|
||
|
||
## 12. Ce que Rubis ne fait PAS (rappel)
|
||
|
||
| Hors-scope | Pourquoi |
|
||
|---|---|
|
||
| ~~Émettre des factures~~ → édité V1.1 : on émet maintenant des factures natives (cf. §11bis et ADR-025), mais on n'est toujours pas un outil de facturation complet (pas d'avoirs, pas de devis, pas d'acomptes en V1.1). | Extension douce, pas pivot. |
|
||
| Devis, avoirs (credit notes), acomptes, facturation récurrente | V2+. La V1.1 reste minimale (factures simples uniquement). |
|
||
| 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. |
|
||
| Émission Factur-X / transmission via PDP (réforme 2026-2027) | V1.5 (Factur-X natif) puis V2 (PDP partenaire si demande client) — cf. ADR-025. |
|
||
|
||
---
|
||
|
||
*Dernière maj : 2026-05-07. Maintenu par Arthur + Claude.*
|