rubis/docs/flow.md
ordinarthur e449b708f3 docs(invoices): édition native + ADR-025 + roadmap Factur-X (Phase 5)
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>
2026-05-14 03:18:11 +02:00

32 KiB
Raw Permalink Blame History

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.tscaptureEmailIfDemo() :

  • 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 pendingpaid 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) :

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 brouillonPOST /invoices/native avec draft: true. Statut pending, sequence_number = null, numero = "BROUILLON-XXXX". Pas de check-in programmé.
    • Émettre la facturePOST /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.renderToBufferuploadBuffer(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.