Compare commits

...

2 Commits

Author SHA1 Message Date
ordinarthur
d410ae014e docs: flow.md — cycle de vie facture, statuts, surfaces UI, check-in deep dive
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 22s
Nouvelle doc orientée comportement produit : explique précisément ce que
fait Rubis du point de vue user-lambda. Pour les arch/tech → architecture.md.
Pour la spec features → produit.md.

Sections :
  1. Modèle mental
  2. Glossaire (rubis, plan, étape, confirmation, mise en demeure, DSO, LME)
  3. Cycle de vie d'une facture (6 statuts + diagramme transitions ASCII +
     détails par transition avec effets en cascade)
  4. Surfaces UI où l'user agit (modale check-in, email check-in, fiche
     facture, slide-over démo) — avec différences mobile/desktop
  5. Mécanique de confirmation deep-dive (le coeur du produit)
  6. Plans de relance (structure, plans pré-fournis, wizard custom, vars)
  7. Mode démo (flag, fork point unique, horloge virtuelle)
  8. KPIs & calculs (rubis, encaissé, DSO, pipeline)
  9. Edge cases & règles
  10. Métriques produit à instrumenter
  11. Ce que Rubis ne fait PAS

CLAUDE.md mis à jour pour pointer vers cette doc dans la liste des
documents associés.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:26:59 +02:00
ordinarthur
52e78b66e9 feat(mobile): UX cohérente sur toute l'app + check-in non-persistant
Mobile UX :
  - Inputs/Textarea : font-size base (16px) sur mobile pour bloquer le zoom
    iOS Safari au focus. Densité 15px préservée sur lg.
  - KpiCard : padding p-4 + value text-22px sur mobile, p-7 + 28px sur lg.
    Truncate pour éviter le débordement des €tots longs.
  - Mobile tab bar : "Réglages" remplacé par "+ Nouvelle" → /factures/import
    (Réglages reste accessible via l'avatar UserMenu).
  - Brand topbar mobile : cliquable → retour dashboard.
  - DemoClock : full-width mobile (inset-x-4), 300px droite sur lg.
  - DemoEmailSlide : bottom-sheet sur mobile (slide-from-bottom + drag handle
    + safe-area-inset-bottom), slide-over droit sur lg.
  - InAppCheckinModal : bottom-sheet mobile aussi, layout InvoiceCard
    stacké pour éviter le squash sur 320px.
  - Nouveau bouton "+ Nouvelle facture" dans le header /factures (visible
    desktop + mobile, link → /factures/import).
  - Wiring des actions mobile dashboard ("Photo de facture" + "Saisir") qui
    n'avaient pas d'onClick — branchés sur /factures/import et le manual
    dialog.

Check-in :
  - "Relancer maintenant" sur la fiche facture : actif pour pending et
    awaiting_user_confirmation. Délègue à inappRespondPending → schedule
    relances + status → in_relance + record activity.
  - InAppCheckinModal : drop le sessionStorage `rubis.checkin.dismissed`.
    Au refocus de l'onglet, dismissed reset à false → la modale re-pop
    si pending non vide. TanStack Query refetch sur focus en bonus.

Plans :
  - PlanCard : ajout d'un mb-6 sur la liste des steps pour aérer le
    divider du footer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:23:31 +02:00
14 changed files with 782 additions and 171 deletions

View File

@ -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 |

View File

@ -1,5 +1,13 @@
import { useMemo, useState } from "react";
import { Check, AlertCircle, ArrowRight, FileText, Calendar } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import {
Check,
AlertCircle,
ArrowRight,
FileText,
Calendar,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
@ -11,64 +19,71 @@ import {
import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button";
const SESSION_DISMISS_KEY = "rubis.checkin.dismissed";
/**
* Modale qui se déclenche au login si l'org a des factures en
* Modale qui se déclenche quand l'org a des factures en
* `awaiting_user_confirmation`. Pour chacune, l'user répond directement :
* - "Oui, payée" mark paid + cancel relances
* - "Non, impayée" schedule relances + status in_relance
* - "Plus tard" on passe à la suivante, on ne touche pas la facture
* - "Plus tard" skip session-only, on passe à la suivante
*
* UX :
* - Mobile : bottom-sheet qui slide up depuis le bas, full-width avec
* safe-area-inset-bottom, coins arrondis en haut, contenu
* scrollable. Plus de pouce-friendly.
* - Desktop : modale centrée classique, max 540px.
*
* Stratégie de queue : on s'appuie sur le serveur comme source de vérité.
* Après chaque réponse, l'invoice quitte le statut `awaiting_user_confirmation`
* et disparaît du refetch. On affiche **toujours queue[0]**, donc la
* suivante remonte naturellement à la position 0 pas de cursor à gérer.
*
* Pour "Plus tard" (skip), on garde un set local d'IDs ignorés cette
* session, qu'on filtre côté client (le serveur les retournera toujours
* tant qu'elles sont awaiting_user_confirmation).
* Stratégie de re-pop :
* - X (close) ferme pour MAINTENANT seulement
* - Refocus / visible reset le dismiss si pending non vide après
* refetch automatique TanStack Query, la modale
* re-pop pour rappeler à l'user de répondre.
* - "Plus tard" persist en mémoire le temps de la session on ne
* re-spame pas la même facture toutes les 5 minutes.
*/
export function InAppCheckinModal() {
const { data: pending = [], isLoading } = usePendingCheckins();
const paidMutation = useCheckinPaid();
const stillPendingMutation = useCheckinStillPending();
// IDs ignorés cette session (skip "Plus tard"). Persiste en mémoire
// pendant la vie du composant, perdu au refresh — ce qui est le but :
// l'user retombe dessus au prochain login.
const [skipped, setSkipped] = useState<Set<string>>(new Set());
// dismissed = "fermé maintenant" (X). Pas persistant — au refocus de
// la fenêtre, on reset à false pour redonner sa chance à la modale.
const [dismissed, setDismissed] = useState(false);
// sessionStorage flag — true = l'user a explicitement fermé (X), on
// ne ré-ouvre pas tant qu'il ne reload pas l'onglet.
const [dismissed, setDismissed] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return sessionStorage.getItem(SESSION_DISMISS_KEY) === "1";
});
// Reset le dismiss au refocus de l'onglet ou au retour de visibilité.
// TanStack Query refetch déjà sur focus (par défaut) → si pending
// change pendant qu'on était sur un autre onglet, la modale reflète.
useEffect(() => {
const reset = () => setDismissed(false);
const onVisibility = () => {
if (document.visibilityState === "visible") reset();
};
window.addEventListener("focus", reset);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("focus", reset);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// Queue = pending serveur, moins les skippés locaux. Ordre serveur
// (échéance asc) préservé.
const queue = useMemo<PendingCheckinInvoice[]>(
() => pending.filter((p) => !skipped.has(p.id)),
[pending, skipped],
);
const current = queue[0];
const totalSeen = pending.length; // utilisé pour le compteur "X / Y"
const totalSeen = pending.length;
const positionLeft = queue.length;
const shouldOpen = !isLoading && !dismissed && queue.length > 0;
const handleClose = () => {
sessionStorage.setItem(SESSION_DISMISS_KEY, "1");
setDismissed(true);
};
@ -77,8 +92,6 @@ export function InAppCheckinModal() {
paidMutation.mutate(current.id, {
onSuccess: () => {
toast.success(`${current.numero} marquée encaissée. + 1 rubis.`);
// Le refetch va retirer cette invoice de pending — current devient
// automatiquement la suivante (queue[0]).
},
onError: () =>
toast.error("Impossible de marquer la facture. Réessayez."),
@ -109,120 +122,181 @@ export function InAppCheckinModal() {
const isPending =
paidMutation.isPending || stillPendingMutation.isPending;
// Position courante = totalSeen - positionLeft + 1, pour avoir "1/3, 2/3…"
// même si la queue rétrécit après chaque réponse.
const cursorLabel = `${totalSeen - positionLeft + 1} / ${totalSeen}`;
const remaining = positionLeft - 1;
return (
<Dialog
<DialogPrimitive.Root
open={shouldOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent maxWidth={520}>
<DialogHeader>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Confirmation · {cursorLabel}
</p>
<DialogTitle className="mt-1">
Avez-vous é <em className="text-rubis not-italic">payé</em> sur cette facture ?
</DialogTitle>
<DialogDescription className="mt-1.5">
Aucune relance ne part sans votre validation. Si la facture est
réglée, on évite l'email inutile et on encaisse +1 rubis.
</DialogDescription>
</DialogHeader>
<InvoiceCard invoice={current} />
<div className="mt-5 flex flex-col gap-2.5">
<Button
size="md"
variant="primary"
loading={paidMutation.isPending}
disabled={isPending}
onClick={onPaid}
className="w-full justify-start"
>
<Check size={15} aria-hidden="true" />
Oui la facture est payée
</Button>
<Button
size="md"
variant="secondary"
loading={stillPendingMutation.isPending}
disabled={isPending}
onClick={onStillPending}
className="w-full justify-start"
>
<AlertCircle size={15} aria-hidden="true" />
Non toujours en attente, lance les relances
</Button>
</div>
<div className="mt-4 flex items-center justify-between">
<button
type="button"
onClick={onSkip}
disabled={isPending}
className={cn(
"text-[12.5px] text-ink-3 hover:text-rubis underline-offset-4 hover:underline cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
>
Plus tard passer à la suivante
</button>
{remaining > 0 && (
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1">
<ArrowRight size={11} aria-hidden="true" />
{remaining} autre{remaining > 1 ? "s" : ""} après
</p>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-ink/35 backdrop-blur-[2px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
)}
</div>
</DialogContent>
</Dialog>
/>
<DialogPrimitive.Content
className={cn(
"fixed z-50 bg-cream shadow-card overflow-y-auto",
// — Mobile : bottom-sheet
"bottom-0 inset-x-0 max-h-[92vh] rounded-t-[20px] border-t border-line",
"px-5 pt-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]",
"data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom",
"data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom",
// — Desktop : modale centrée classique
"sm:bottom-auto sm:inset-x-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2",
"sm:w-[calc(100vw-2rem)] sm:max-w-[520px] sm:max-h-[90vh]",
"sm:rounded-card sm:border sm:border-line sm:p-7",
"sm:data-[state=open]:slide-in-from-bottom-0 sm:data-[state=open]:zoom-in-95",
"sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=closed]:zoom-out-95",
)}
>
{/* Drag handle visuel — exclusivement mobile, signal "tu peux fermer". */}
<div
aria-hidden="true"
className="sm:hidden mx-auto mb-4 h-1 w-10 rounded-full bg-ink/15"
/>
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Confirmation · {cursorLabel}
</p>
<DialogPrimitive.Title
className={cn(
"mt-1 font-display font-semibold tracking-[-0.018em] text-ink",
"text-[19px] leading-tight",
)}
>
Avez-vous é{" "}
<em className="text-rubis not-italic">payé</em> sur cette facture ?
</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-1.5 text-[13px] text-ink-3 leading-relaxed">
Aucune relance ne part sans votre validation. Si la facture est
réglée, on évite l'email inutile et on encaisse +1 rubis.
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close
className={cn(
"shrink-0 inline-flex size-8 items-center justify-center cursor-pointer",
"rounded-default text-ink-3 hover:bg-cream-2 hover:text-ink",
"transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label="Fermer"
>
<X size={16} aria-hidden="true" />
</DialogPrimitive.Close>
</div>
{/* Card facture */}
<div className="mt-5">
<InvoiceCard invoice={current} />
</div>
{/* Actions principales */}
<div className="mt-5 flex flex-col gap-2.5">
<Button
size="md"
variant="primary"
loading={paidMutation.isPending}
disabled={isPending}
onClick={onPaid}
className="w-full justify-start"
>
<Check size={15} aria-hidden="true" />
Oui la facture est payée
</Button>
<Button
size="md"
variant="secondary"
loading={stillPendingMutation.isPending}
disabled={isPending}
onClick={onStillPending}
className="w-full justify-start"
>
<AlertCircle size={15} aria-hidden="true" />
Non toujours en attente, lance les relances
</Button>
</div>
{/* Footer : Plus tard + compteur restant */}
<div className="mt-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onSkip}
disabled={isPending}
className={cn(
// Tap target ≥ 44pt — px-3 py-2.5 = 40-44px height
"inline-flex items-center px-3 py-2.5 -ml-3 rounded-default cursor-pointer",
"text-[13px] text-ink-3 hover:text-rubis transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
>
Plus tard
</button>
{remaining > 0 && (
<p className="text-[11.5px] text-ink-3 italic flex items-center gap-1 shrink-0">
<ArrowRight size={11} aria-hidden="true" />
{remaining} autre{remaining > 1 ? "s" : ""} après
</p>
)}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
/** Petite fiche récap de la facture concernée. */
/**
* Fiche récap de la facture adaptée mobile.
* Au lieu de flex-row amount/date qui squashe à 320px, on stacke
* verticalement et on aligne tout à gauche pour la lisibilité pouce-only.
*/
function InvoiceCard({ invoice }: { invoice: PendingCheckinInvoice }) {
const isLate = isOverdue(invoice.dueDate);
const dueLabel = formatDueDelta(invoice.dueDate);
return (
<div className="rounded-card border border-line bg-white px-4 py-3.5">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" />
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
{invoice.numero}
</p>
</div>
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p>
</div>
</div>
<div className="flex items-end justify-between gap-3">
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink">
{formatEuros(invoice.amountTtcCents)}
<div className="rounded-card border border-line bg-white px-4 py-4">
{/* Header — numéro + client */}
<div className="flex items-center gap-2">
<FileText size={14} className="text-ink-3 shrink-0" aria-hidden="true" />
<p className="font-display text-[14.5px] font-semibold tracking-tight text-ink truncate">
{invoice.numero}
</p>
<div className="text-right">
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums">
<Calendar size={11} aria-hidden="true" />
<span>échue le {formatDate(invoice.dueDate)}</span>
</div>
<p
className={cn(
"mt-0.5 text-[11.5px] font-medium tabular-nums",
isLate ? "text-rubis-deep" : "text-ink-3",
)}
>
{dueLabel}
</p>
</div>
</div>
<p className="mt-0.5 text-[12.5px] text-ink-2 truncate">
{invoice.clientName}
</p>
{/* Montant XL en feature */}
<p className="mt-3 font-display text-[26px] font-bold tabular-nums leading-none text-ink">
{formatEuros(invoice.amountTtcCents)}
</p>
{/* Date + retard, sur la même ligne mais avec wrap propre */}
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[12px]">
<span className="inline-flex items-center gap-1 text-ink-3 tabular-nums">
<Calendar size={11} aria-hidden="true" />
échue le {formatDate(invoice.dueDate)}
</span>
<span
className={cn(
"font-medium tabular-nums",
isLate ? "text-rubis-deep" : "text-ink-3",
)}
>
({dueLabel})
</span>
</div>
{invoice.planName && (
<p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3">
Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong>

View File

@ -26,11 +26,28 @@ type KpiCardProps = {
export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) {
return (
<Card padding="md" className={cn("min-w-0", className)}>
<Card
padding="none"
className={cn(
// Padding plus serré sur mobile (sinon les chiffres TTC longs débordent
// sur les cards 2-cols à 375px).
"min-w-0 p-4 lg:p-7",
className,
)}
>
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
{label}
</p>
<p className="mt-2 font-display text-[28px] font-bold leading-none tracking-[-0.018em] text-ink tabular-nums">
<p
className={cn(
"mt-2 font-display font-bold leading-none tracking-[-0.018em] text-ink tabular-nums",
// 22px sur mobile, 28px à partir de lg : "401 240 €" tient sur 1 ligne
// dans les cards 2-cols même sur petit écran.
"text-[22px] lg:text-[28px]",
// Empêche le débordement quand la string est très longue.
"truncate",
)}
>
{value}
</p>
{delta && (

View File

@ -73,7 +73,10 @@ export function DemoClock() {
<>
<div
className={cn(
"fixed top-4 right-4 z-30 w-[300px]",
// Mobile : full-width avec marges symétriques.
// Desktop : 300px aligné top-right.
"fixed top-4 inset-x-4 z-30",
"sm:left-auto sm:right-4 sm:inset-x-auto sm:w-[300px]",
"rounded-card border border-rubis-glow bg-white shadow-card",
"px-4 py-3",
)}

View File

@ -131,20 +131,31 @@ export function DemoEmailSlide({
type="button"
aria-label="Fermer"
onClick={onContinue}
className="fixed inset-0 z-40 bg-ink/10 backdrop-blur-[2px] cursor-default"
className="fixed inset-0 z-40 bg-ink/35 backdrop-blur-[2px] cursor-default"
/>
<aside
role="dialog"
aria-label="Émission Rubis pendant la démo"
className={cn(
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px]",
"bg-cream border-l border-line shadow-card flex flex-col",
"animate-in slide-in-from-right duration-200",
"fixed z-50 bg-cream shadow-card flex flex-col",
// — Mobile : bottom-sheet
"bottom-0 inset-x-0 max-h-[92vh] rounded-t-[20px] border-t border-line",
"animate-in slide-in-from-bottom duration-200",
// — Desktop : slide-over droit
"sm:bottom-auto sm:inset-x-auto sm:top-0 sm:right-0 sm:h-screen sm:max-h-none",
"sm:w-full sm:max-w-[520px] sm:rounded-none sm:border-l sm:border-t-0",
"sm:slide-in-from-right",
)}
>
{/* Drag handle visuel — mobile only, signal "tu peux fermer". */}
<div
aria-hidden="true"
className="sm:hidden mx-auto mt-2.5 mb-1 h-1 w-10 rounded-full bg-ink/15 shrink-0"
/>
{/* Header — titre adapté à l'event */}
<div className="flex items-center gap-3 border-b border-line bg-white px-5 py-4">
<div className="flex items-center gap-3 border-b border-line bg-white px-5 py-3.5 shrink-0">
<span
className={cn(
"shrink-0 size-9 flex items-center justify-center rounded-full",
@ -164,17 +175,30 @@ export function DemoEmailSlide({
<button
type="button"
onClick={onContinue}
className="size-7 flex items-center justify-center rounded-full text-ink-3 hover:bg-cream-2"
className={cn(
"size-8 flex items-center justify-center rounded-default cursor-pointer",
"text-ink-3 hover:bg-cream-2 hover:text-ink transition-colors",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label="Fermer"
>
<X size={14} />
<X size={16} />
</button>
</div>
{/* Body — change selon l'étape */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
{/* Card facture — toujours visible : contexte + lien vers la fiche */}
<InvoiceCard invoice={invoice ?? null} fallbackNumero={event.invoiceNumero} onNavigate={onContinue} />
{/* Body change selon l'étape. Padding-bottom safe-area quand pas
de footer (étape "ask"), sinon le footer s'en occupe. */}
<div
className={cn(
"flex-1 overflow-y-auto px-5 py-5 space-y-5",
step === "ask" && "pb-[max(1.25rem,env(safe-area-inset-bottom))]",
)}
>
<InvoiceCard
invoice={invoice ?? null}
fallbackNumero={event.invoiceNumero}
onNavigate={onContinue}
/>
{step === "ask" && (
<AskStep
@ -188,16 +212,23 @@ export function DemoEmailSlide({
{step === "paid" && <PaidStep invoiceNumero={event.invoiceNumero} />}
</div>
{/* Footer — uniquement à l'étape email/paid (l'étape ask a ses propres boutons) */}
{/* Footer uniquement à l'étape email/paid (l'étape ask a ses propres boutons).
Padding inférieur safe-area pour les iPhones notchés. */}
{step !== "ask" && (
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between">
<p className="text-[11.5px] text-ink-3 italic">
<div
className={cn(
"border-t border-line bg-white px-5 py-4 shrink-0",
"flex items-center justify-between gap-3",
"pb-[max(1rem,env(safe-area-inset-bottom))]",
)}
>
<p className="text-[11.5px] text-ink-3 italic min-w-0">
{remaining > 0
? `${remaining} autre${remaining > 1 ? "s" : ""} event${remaining > 1 ? "s" : ""} en file`
: "Cliquez pour reprendre l'horloge"}
</p>
<Button size="sm" onClick={onContinue}>
Continuer la démo <ArrowRight size={14} />
<Button size="sm" onClick={onContinue} className="shrink-0">
Continuer <ArrowRight size={14} />
</Button>
</div>
)}

View File

@ -1,3 +1,4 @@
import { Link } from "@tanstack/react-router";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
@ -40,10 +41,18 @@ export function AppTopbar({ title, subtitle, actions }: AppTopbarProps) {
)}
>
<div className="flex items-center justify-between gap-4 px-5 py-4 lg:px-8 lg:py-5">
{/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting. */}
<div className="lg:hidden">
{/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting.
Le brand est cliquable retour au dashboard. */}
<Link
to="/"
aria-label="Retour au dashboard"
className={cn(
"lg:hidden rounded-default cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)}
>
<Brand />
</div>
</Link>
<div className="hidden lg:block min-w-0">
<h1 className="font-display text-[19px] font-semibold tracking-[-0.018em] text-ink leading-tight">
{greeting}

View File

@ -1,9 +1,15 @@
import { Home, FileText, ListChecks, Settings } from "lucide-react";
import { Home, FileText, ListChecks, Plus } from "lucide-react";
import { NavLink } from "./NavLink";
/**
* Tab bar mobile fixed bottom, 4 entrées max.
*
* Pas de "Clients" : on y accède depuis une facture (cf. wireframe 4.3).
* Pas de "Réglages" non plus : disponible via l'avatar topbar (UserMenu).
*
* Slot 4 = "+ Nouvelle facture" (= /factures/import) l'action la plus
* fréquente sur mobile (photo + drop), accessible en 1 tap depuis n'importe
* dans l'app.
*/
export function MobileTabBar() {
return (
@ -26,10 +32,10 @@ export function MobileTabBar() {
label="Plans"
/>
<NavLink
to="/parametres"
to="/factures/import"
variant="tab-bar"
icon={<Settings size={19} />}
label="Réglages"
icon={<Plus size={19} />}
label="Nouvelle"
/>
</div>
</nav>

View File

@ -82,7 +82,7 @@ export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps)
</p>
)}
<ul className="mt-4 flex flex-col gap-1.5 text-[12.5px] text-ink-2">
<ul className="mt-4 mb-6 flex flex-col gap-1.5 text-[12.5px] text-ink-2">
{plan.steps.slice(0, 3).map((step) => (
<li key={step.id} className="flex items-baseline gap-2">
<span

View File

@ -18,7 +18,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
className={cn(
// Base
"block w-full rounded-default border border-line bg-white px-3.5 py-3",
"font-sans text-[15px] text-ink placeholder:text-ink-3",
// 16px MIN sur mobile pour empêcher iOS Safari de zoomer au focus
// (≥16px = pas de zoom). On revient à 15px sur desktop pour densité.
"font-sans text-base lg:text-[15px] text-ink placeholder:text-ink-3",
// Transitions
"transition-[border-color,box-shadow] duration-150",
// Focus

View File

@ -15,7 +15,8 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
rows={rows}
className={cn(
"block w-full rounded-default border border-line bg-white px-3.5 py-3",
"font-sans text-[15px] text-ink placeholder:text-ink-3",
// 16px MIN sur mobile pour empêcher iOS Safari de zoomer au focus.
"font-sans text-base lg:text-[15px] text-ink placeholder:text-ink-3",
"transition-[border-color,box-shadow] duration-150 resize-none",
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",

View File

@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
@ -17,6 +18,7 @@ import {
type InvoiceListItem,
} from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
import { Button } from "@/components/ui/Button";
import { Pagination } from "@/components/ui/Pagination";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices";
@ -172,17 +174,26 @@ function FacturesPage() {
onDragOver={onPageDragOver}
onDrop={onPageDrop}
>
<div>
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Factures{" "}
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
</span>
</h1>
<p className="mt-1 text-[13.5px] text-ink-3">
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
pour voir la timeline.
</p>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Factures{" "}
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
</span>
</h1>
<p className="mt-1 text-[13.5px] text-ink-3">
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
pour voir la timeline.
</p>
</div>
<Button size="sm" asChild className="shrink-0">
<Link to="/factures/import">
<Plus size={14} aria-hidden="true" />
<span className="hidden sm:inline">Nouvelle facture</span>
<span className="sm:hidden">Nouvelle</span>
</Link>
</Button>
</div>
<FilterChips

View File

@ -7,6 +7,7 @@ import { z } from "zod";
import type { Client, Invoice, Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { useCheckinStillPending } from "@/lib/checkin";
import { queryKeys } from "@/lib/queryKeys";
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
@ -70,6 +71,11 @@ function InvoiceDetailPage() {
},
});
// "Relancer maintenant" : court-circuite le check-in, programme les
// relances + bascule l'invoice en `in_relance` immédiatement. Réutilise
// l'endpoint inappRespondPending qui fait exactement ça.
const launchRelanceMutation = useCheckinStillPending();
useEffect(() => {
const checkin = search.checkin;
if (!checkin) return;
@ -165,7 +171,30 @@ function InvoiceDetailPage() {
>
<Check size={14} aria-hidden="true" /> Marquer encaissée
</Button>
<Button size="sm" disabled>
{/* "Relancer maintenant" : actif pour pending et awaiting_user_confirmation
court-circuite le check-in et déclenche immédiatement le plan.
Désactivé pour `in_relance` car le plan tourne déjà. */}
<Button
size="sm"
loading={launchRelanceMutation.isPending}
disabled={
invoice.status !== "pending" &&
invoice.status !== "awaiting_user_confirmation"
}
onClick={() =>
launchRelanceMutation.mutate(invoice.id, {
onSuccess: () => {
toast.success(
`Relances activées pour ${invoice.numero}.`,
);
},
onError: () =>
toast.error(
"Impossible de programmer les relances. Réessayez.",
),
})
}
>
<Send size={14} aria-hidden="true" /> Relancer maintenant
</Button>
</div>

View File

@ -11,6 +11,7 @@ import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { RubisHero } from "@/components/dashboard/RubisHero";
import { KpiCard } from "@/components/dashboard/KpiCard";
import {
@ -72,6 +73,7 @@ export const Route = createFileRoute("/_app/")({
});
function DashboardPage() {
const manual = useManualInvoice();
const { data: kpis } = useQuery({
queryKey: queryKeys.dashboard.kpis(),
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
@ -96,10 +98,17 @@ function DashboardPage() {
<div className="flex flex-col gap-6 lg:gap-7">
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
<div className="flex gap-2 lg:hidden">
<Button size="sm" className="flex-1">
<Camera size={15} aria-hidden="true" /> Photo de facture
<Button size="sm" className="flex-1" asChild>
<Link to="/factures/import">
<Camera size={15} aria-hidden="true" /> Photo de facture
</Link>
</Button>
<Button size="sm" variant="secondary" className="flex-1">
<Button
size="sm"
variant="secondary"
className="flex-1"
onClick={manual.open}
>
<Plus size={15} aria-hidden="true" /> Saisir
</Button>
</div>

418
docs/flow.md Normal file
View File

@ -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.*