- MAIL_FROM_ADDRESS: rubis@arthurbarre.fr → contact@rubis.pro
Les emails Rubis partent désormais du domaine principal,
cohérent avec la marque et avec la boîte OVH MX Plan créée
pour recevoir les replies clients.
- MAIL_FROM_NAME: "Rubis Sur l'Ongle" → "Rubis sur l'ongle"
(alignement casse brand)
- LANDING_URL: ajoute la clé manquante (https://rubis.pro)
qui n'était pas trackée dans le ConfigMap du repo. Le footer
des emails utilisait l'ancienne valeur stale du cluster.
Pré-requis : domaine rubis.pro Verified dans Resend (DKIM + SPF).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- "Commencer l'essai 14 jours" → "Commencer l'essai 30 jours" et
lien vers https://app.rubis.pro (au lieu de #waitlist obsolète)
- "Démarrer Free maintenant" → lien vers app.rubis.pro
- "Démarrer Business maintenant" → lien vers app.rubis.pro
30 jours = sweet spot SMB B2B (couvre 1 cycle de relance complet
J+3/J+10/J+20, conserve l'urgence sans tomber dans l'effet
d'ancrage du gratuit long).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mise à jour terminologique : depuis le 1er juillet 2022 (réforme
ordonnance n° 2016-728), les huissiers de justice et commissaires-
priseurs judiciaires ont fusionné en une profession unifiée :
"commissaire de justice".
Garde le terme exact à jour dans le bloc "cadre légal" du landing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Documentation post-migration du setup email :
- /docs/tech/backend.md §12.5 : architecture des 2 flux
(Resend pour le sortant transactionnel via send.rubis.pro,
OVH MX Plan pour l'entrant humain via @ rubis.pro)
- /CLAUDE.md : tableau récap email infra + maj domaine principal
rubis.pro / app.rubis.pro, suppression de la question ouverte
"domaine définitif" (résolue) et "endpoint waitlist" (remplacé
par CTA app)
- /.claude/deploy-memory.md : section migration rubis.pro marquée
✅ avec checklist décommissionnement legacy
- /landing/confidentialite.html : remplace privacy@rubis.pro
par contact@rubis.pro (alignement avec les boîtes OVH créées)
Adresses opérationnelles :
- contact@rubis.pro (général + RGPD)
- dev@rubis.pro (notifs techniques)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bascule du domaine principal vers rubis.pro / app.rubis.pro :
- K3s ConfigMaps (api.yml, web.yml) : APP_URL, WEB_URL,
COOKIE_DOMAIN, OAUTH callbacks pointent vers app.rubis.pro
- Dockerfile.web : ARG VITE_API_URL et VITE_PUBLIC_LANDING_URL
- Workflows Gitea : commentaires + build args web → rubis.pro
- Code API (mail_dispatcher, send_test_email, config/mail) :
defaults env LANDING_URL et MAIL_FROM_ADDRESS migrés
- Templates env (.env.example) idem
- Docs (architecture, backend, frontend, brand-identity) idem
- AGENTS.md / CLAUDE.md / deploy-memory : pointeurs domaine MAJ
Note : MAIL_FROM_ADDRESS dans le secret K3s reste sur
rubis@arthurbarre.fr tant que le domaine rubis.pro n'est pas
Verified dans Resend. À switcher manuellement après vérif Resend.
Compat : un 301 Traefik redirige rubis.arthurbarre.fr → rubis.pro
(et app.X aussi) — config Ansible dans le repo proxmox.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Remplace les formulaires waitlist par des CTA "Lancer Rubis"
pointant vers app.rubis.pro (nav, hero, section finale)
- Met à jour la trust line ("3 mois gratuits puis Free 5 factures")
- Footer : ajoute liens Mentions légales / Confidentialité, casse
"Rubis sur l'ongle" + lien Lancer Rubis
- Supprime le script de binding waitlist (plus utilisé)
- Migre les références au domaine vers rubis.pro
Nouvelles pages :
- mentions-legales.html : conformité LCEN (9 sections)
- confidentialite.html : politique RGPD (10 sections, sous-traitants
Stripe/Resend/Mistral, droits utilisateur, durées de conservation)
- _legal-shell.css : shell graphique partagé (palette rubis,
Bricolage Grotesque + Inter, header/footer brandés, TOC)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Templates HTML stylés DA Rubis pour les 2 emails sortants — fini le
plain text moche.
apps/api/app/mails/
├── _brand.ts : tokens couleur + spacing partagés
├── _layout.tsx : squelette commun (header rubis-deep + footer)
├── checkin_email.tsx : email envoyé À L'USER avec 2 boutons CTA
│ Oui (rubis primary) / Non (outlined)
└── relance_email.tsx : email envoyé AU CLIENT, body texte du plan
+ card récap (numéro, montant, échéance,
badge retard rubis-deep)
Stack :
- @react-email/components + @react-email/render
- Tous les styles inline (compatible Gmail / Outlook / Apple Mail)
- HTML + plain text en fallback (anti-spam, accessibility)
mail_dispatcher.ts :
- sendRelanceEmail : .html(rendered) + .text(body)
- sendCheckinEmail : .html(rendered) + .text(body)
- daysLate calculé via clock.now (démo-aware)
send_test_email :
- Nouveau flag --template=checkin (default) | relance | plain pour
tester chaque rendu via Mailpit sans créer de vraie facture.
Brand & landing :
- "Rubis Sur l'Ongle" → "Rubis sur l'ongle" partout (config, mail,
PDF, Stripe appInfo)
- Nouvelle env var LANDING_URL (default https://rubis.arthurbarre.fr)
- Footer email rend "Rubis sur l'ongle" comme <a> rubis cliquable
vers la landing — l'user qui reçoit le mail connaît la marque
derrière l'envoi
- .env.example mis à jour avec LANDING_URL pour les autres devs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le client final qui reçoit la relance voyait "From: Rubis Sur l'Ongle"
alors qu'il connaît "Arthur Barré" (le patron de la TPE qui utilise
Rubis). Confusion garantie côté client → relance perçue comme spam.
Fix : `sendRelanceEmail` utilise maintenant comme display name "From" :
1. `organization.name` (en priorité — c'est le nom commercial connu
du client)
2. `user.fullName` (fallback si l'org n'a pas de nom posé)
3. `MAIL_FROM_NAME` env (dernier recours, "Rubis Sur l'Ongle" en prod)
L'adresse technique reste sur notre domaine vérifié (relances@arthurbarre.fr
→ SPF/DKIM Resend OK), seul le display name change.
Le mail check-in (envoyé À l'user, pas au client) garde "Rubis Sur l'Ongle"
comme display — c'est nous qui le notifions, c'est cohérent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug V1 documenté dans flow.md mais jamais corrigé : le job send_checkin_job
envoyait l'email + marquait la CheckinTask `sent`, mais ne touchait pas le
statut de la facture. Conséquence : l'user reçoit le mail check-in dans sa
boîte mais la modale in-app au refresh ne l'affiche pas (la modale liste
uniquement les `awaiting_user_confirmation` côté DB).
Fix : après l'envoi mail OK et le mark CheckinTask=sent, on bump
`Invoice.status = 'awaiting_user_confirmation'` SI elle est encore
en `pending`. Pas de bump si entre temps :
- mark-paid (status=paid)
- litigation/cancelled (transitions manuelles)
- in_relance (impossible mais safe)
Doc flow.md mise à jour pour refléter le nouveau comportement (effets
de la transition pending → awaiting + déprécation de la note "TODO V1.5").
Pour les factures existantes en prod qui ont déjà reçu le mail mais
restent en `pending` (cas pré-fix) : backfill manuel via SQL :
UPDATE invoices SET status = 'awaiting_user_confirmation'
WHERE status = 'pending'
AND id IN (
SELECT invoice_id FROM checkin_tasks WHERE status = 'sent'
);
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Force un état billing sur l'org d'un user pour tester rapidement chaque
comportement UI/enforcement sans passer par Stripe ni attendre 3 mois.
Usage :
node ace billing:scenario --email <user> --scenario <name>
Scénarios :
• status : ne touche rien, affiche juste l'état courant
• fresh : reset signup neuf (free + grace 3 mois)
• grace-expired : free, grace terminée, ≤ 5 actives → OK
• limit-reached : free, grace terminée, force 5 actives → bloqué (402)
• pro : pro mensuel actif, fake IDs si pas de vrais
• pro-cancelling : pro + cancel_at_period_end=true → bandeau ANNULÉ
• pro-past-due : pro + status=past_due → warning UI
• business : business mensuel actif
Sécurité : préserve les VRAIS Stripe IDs s'ils existent (= l'org a
déjà payé). Génère des fake `cus_test_FAKE_*` / `sub_test_FAKE_*`
seulement si NULL — ne pas écraser une vraie souscription.
Le command affiche un récap compact à chaque exécution :
- plan / grace / Stripe IDs / status / cancel_at
- factures actives vs limite
- création autorisée ou non + raison
Pour tester un comportement côté UI :
1. Lance le scénario
2. Reload /parametres/abonnement et /factures
3. Vérifie le rendu (bandeau cancel, blocage import, etc.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nouveau dossier `09-Billing/` avec :
- folder.bru (overview : plans + flows upgrade/cancel/reactivate)
- 01 Get subscription : state du plan, caps, grace period, cancel flag
- 02 Start checkout : crée une Checkout Session Stripe (Pro/Business
× monthly/yearly)
- 03 Open portal : Customer Portal pour gérer CB/annulation
- 04 Reactivate : annule l'annulation programmée (sans paiement
immédiat) — gère le conflit Stripe
cancel_at vs cancel_at_period_end
Aussi documenté les endpoints in-app check-in qui manquaient dans Bruno :
- 03 In-app pending : liste des factures awaiting_user_confirmation
- 04 In-app respond paid : équivalent du lien email "C'est payé"
- 05 In-app respond pending : équivalent "Toujours en attente"
README mis à jour avec le parcours étendu (signup → … → billing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug : le Stripe Customer Portal n'utilise pas `cancel_at_period_end:true`
mais `cancel_at:<timestamp>` pour scheduler l'annulation. Notre webhook
ne lisait que le booléen → l'annulation via portail n'était pas remontée
côté DB, l'UI ne montrait jamais le bandeau "annulé".
Webhook handler :
- Détecte l'annulation via EITHER `cancel_at_period_end` OR `cancel_at`
et unifie en un seul booléen `cancelAtPeriodEnd` côté org.
Endpoint /reactivate :
- Stripe REFUSE qu'on passe `cancel_at_period_end:false` ET `cancel_at:null`
dans le même update ("Please pass in only one"). On retrieve d'abord
la sub pour savoir laquelle des 2 mécaniques est active, puis on clear
uniquement celle-là.
Logs enrichis : `cancelAtPeriodEnd` et `cancelAt` désormais loggés à
chaque `applySubscriptionToOrg` pour que le diagnostic soit immédiat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quand l'user annule via le Customer Portal Stripe, la subscription reste
`active` jusqu'à la fin du cycle (cancel_at_period_end=true) — Stripe
n'envoie le `subscription.deleted` qu'à period_end. Avant ce commit, l'UI
affichait toujours "prochaine facture le 28 mai" comme avant l'annulation,
ce qui faisait croire à l'user qu'il allait re-payer.
Backend :
- Migration `cancel_at_period_end boolean DEFAULT false` sur orgs.
- `applySubscriptionToOrg` : lit le flag du Stripe Subscription et
persiste sur l'org.
- `handleSubscriptionDeleted` : reset le flag à false (cohérence DB).
- `OrgSubscriptionState` : nouveau champ `cancelAtPeriodEnd: boolean`.
- Endpoint `POST /api/v1/billing/reactivate` :
• Idempotent (si déjà actif → no-op + 200)
• Appelle `subscriptions.update(id, { cancel_at_period_end: false })`
• Persist le nouvel état sur l'org
Frontend :
- Hook `useReactivateSubscription` (mutation + invalidate billing query).
- `CurrentPlanStrip` :
• Détecte `isCancelling = plan !== 'free' && cancelAtPeriodEnd`
• Switch border/bg en mode rubis-deep + rubis-glow pour attirer l'œil
• Icône Clock à la place de Gem (visuel "compte à rebours")
• Badge "ANNULÉ" en uppercase
• Sous-titre : "Accès Pro jusqu'au DD/MM, puis retour automatique
au plan Free."
• Bouton primary "Réactiver" (RotateCcw icon) qui remplace "Gérer"
• Masque la progress bar Free (non pertinente)
- `SubscriptionState` type étendu avec `cancelAtPeriodEnd`.
- Test factory updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mensuel par défaut dans le toggle (au lieu de yearly).
Layout asymétrique 1fr/1.3fr/1fr avec Pro en hero card centrale, plus
large et plus dense visuellement que Free et Business — sort du pattern
"3 cards égales" générique des landing pages SaaS.
Identité Rubis :
- Gem 280px en filigrane sur la Pro card (rubis-glow, opacity 70%) —
le motif signature de la marque, pas un blob de gradient
- Bullets ◆ losanges rubis tournés (5×5px) au lieu des check-circles
Lucide génériques — cohérent avec PlanCard, Timeline, etc.
- Pastille "Le plus pris" avec mini-gem inline sur Pro
- Border rubis + shadow-card sur Pro, line + sobre pour les voisines
- Free : bg cream-2 (ton "découverte"), Business : bg blanc + accents
rubis-deep
- Strip "plan courant" : remplace le gros bloc card → ligne flex avec
gem + texte + mini progress bar 32px + bouton ghost. Discret.
Voice direct dans les bénéfices :
- Header narratif "Trois mois pour voir. Puis vous décidez."
- "Tester sans poser sa CB" (Free)
- "Pour les TPE qui n'ont plus jamais à y penser" (Pro titre)
- "Moins cher qu'une heure de votre temps mensuel" (Pro footer)
- Annulation 1-clic mentionné en sous-titre du toggle
Prix Pro XL 44px (vs 28px pour les voisines) — hiérarchie visuelle qui
guide l'œil vers l'option qu'on veut pousser.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
L'app est désormais installable sur écran d'accueil (Android via Chrome,
iOS via Safari → Partager → Sur l'écran d'accueil). Identité visuelle
strictement alignée sur le SPA :
- background_color : crème (#FAF7F2)
- theme_color : rubis primaire (#9F1239)
- icon : gem 4-facettes sur canvas crème, padding 15% pour
safe-area maskable Android tout en remplissant
suffisamment iOS qui ne masque pas
3 formats générés depuis le SVG via @resvg/resvg-js :
- icon-192.png + icon-512.png (manifest, splashscreen Android)
- apple-touch-icon.png 180×180 (iOS home screen)
Plus la SVG vectorielle servie en favicon.
Le script `pnpm --filter @rubis/web run icons` re-génère tout depuis
`public/icon.svg` — utile si on retouche le design.
Drop le favicon.svg de 1.1 MB hérité du landing (vestige), remplacé par
notre gem propre à 1.1 KB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sidebar : nouveau bouton chevron en bas qui replie/déplie (240px ↔ 68px).
Choix persisté en localStorage (`rubis.sidebar.collapsed`). En mode replié :
- Brand → gem seule (28px)
- NavLinks → icône centrée + tooltip Radix au hover qui montre le label
- Compteur rubis → version ultra-compacte (gem 16px + chiffre empilés),
tooltip réaffiche "Rubis ce mois · ≈ Xh libérées"
- Marqueur rubis vertical de l'item actif préservé
Gem : refonte du SVG. 4 facettes triangulaires se rejoignent au centre,
chacune avec une opacité différente (1.0 / 0.8 / 0.65 / 0.48) pour suggérer
le jeu de lumière d'une pierre taillée — sans gradient, qui devient pâteux
à petite taille. Contour propre `strokeLinejoin: round` pour la silhouette.
Drop `logo.png` : `<Brand/>` utilise maintenant la Gem SVG en interne. Plus
aucune dépendance à l'asset PNG bordé/arrondi qui rendait flou aux grosses
tailles. Toutes les surfaces (sidebar, RubisHero, compteur) partagent la
même icône scalable héritant de `currentColor`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chaque facture seedée a maintenant son propre PDF dont le contenu
matche exactement les meta DB (vendeur = org du user, client = client
réel, numéro / dates / montant cohérents). Plus de réutilisation
round-robin de PDFs disque non-cohérents.
Stack :
- @react-pdf/renderer : composants React déclaratifs, StyleSheet
inspiré du SPA (mêmes tokens couleur Rubis), même mental model que
le frontend.
- InvoiceDocument décomposé en sous-composants Header / Addresses /
ItemsTable / Totals / Footer pour itération facile.
- Items générés depuis un pool B2B (conseil, dev, audit, formation,
livraison, photo, …) avec quantités/prix unitaires qui s'ajustent
pour que la somme matche le total TTC stocké.
Le command `seed:demo --reset` :
- wipe les invoice-pdfs/{orgId}/* sur MinIO (paginé)
- re-génère 227 PDFs (27 actionnables + 200 historiques)
- CA cumulé paid ≈ 400 K€ pile sur la cible
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Dockerfile.api : copie `assets/test-invoices/` dans l'image (les 27
PDFs servent au seed démo, ~80KB, négligeable).
- factories.ts : ajout d'un 3e candidat de chemin pour couvrir le
contexte prod où la commande tourne depuis `/app/apps/api/build`.
Permet de peupler une org démo en prod via :
kubectl -n rubis exec -it deploy/rubis-api -- \\
sh -c 'cd /app/apps/api/build && node ace.js seed:demo \\
--email <user> --reset --org-name="<nom>"'
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
config/logger.ts: en dev, on duplique les logs vers
apps/api/storage/logs/app.log via un `multistream` pino (pretty stdout
+ JSON file en parallèle). Plus fiable que `transport.targets` qui
tourne dans un worker thread et fail silencieusement quand le path
n'est pas accessible.
Logs ciblés sur le pipeline relance pour debug rapide :
- relance_scheduler : tâche créée + delaySec + queueJobId
- send_relance_job : pick-up / skip / envoi / OK / KO
- mail_dispatcher : driver actif (smtp/resend) + send OK / err
.gitignore : storage/uploads + storage/logs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Permet à l'user de répondre aux check-ins directement dans l'app, sans
passer par les liens email. Au mount du layout `_app`, on liste les
factures en `awaiting_user_confirmation` et on les présente une par une
dans une modale séquentielle :
- "Oui, payée" → mark paid + bonus rubis + cancel relances
- "Non, en attente" → schedule relances + status → in_relance
- "Plus tard" → skip session-only
3 endpoints auth-protected sous /api/v1/checkin/inapp/ (déclarés AVANT
le groupe public à token sinon /:token/pending mange /inapp/pending).
La modale fait toujours confiance au serveur : queue = pending refetch,
display = queue[0], pas de cursor manuel — sinon on saute des factures
quand le serveur retire la réponse précédente.
Wording rassurant : "Aucune relance ne part sans votre validation".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Permet de faire vivre Rubis en accéléré pour démontrer le produit à des
prospects, SANS impacter la prod. Les vrais users ont demoMode=false par
défaut → toute la logique démo est court-circuitée.
Architecture (priorité : zéro impact prod, codebase propre)
Phase 1 — Abstraction Clock
- Migration : organizations.demo_mode + virtual_now + demo_speed_factor
(défaut false/null/1, zéro effet sur les orgs existantes)
- services/clock.ts : now(orgId?) → DateTime.utc() en prod, virtualNow
en démo. Cache mémoire 250ms pour pas spammer la DB. Helpers
setVirtualNow / setDemoMode pour les transitions.
- Refacto 7 fichiers : relance_scheduler, checkin_scheduler, dashboard,
send_relance_job, send_checkin_job, mail_dispatcher (buildRelanceVars
daysLate), activity_recorder, checkin_controller, invoices_controller
(buildTimeline + markPaid). DateTime.now() → clock.now(orgId).
- Tests existants (51) passent identique → preuve que la prod est intacte.
Phase 2 — Capture emails + dispatch
- Migration : demo_captured_emails (kind, to, from, subject, body, sent_at,
meta) — index sur (org, sent_at desc) pour l'inbox.
- services/demo/capture.ts : captureEmailIfDemo() — UNIQUE point de fork
dans la prod (deux lignes dans mail_dispatcher : if captured return).
Hors démo, fonction retourne false → flux Resend inchangé.
- services/demo/dispatch.ts : tickAndDispatch(orgId, target) → bump
virtual_now, trouve les tasks dues (relance + checkin), invoke les
handlers existants synchronement (skip BullMQ, propre). Retourne les
events fired pour l'UI.
- POST /api/v1/demo/{start,end,tick} + GET /demo/{state,inbox}, toutes
protégées par requireDemoOrg() (403 si demoMode=false).
Phase 3 — UI horloge "vivante"
- lib/demo.ts : useDemoState, useDemoTick (boucle rAF locale qui avance
virtualNow à `speed * elapsed` jours/sec, sync backend toutes les
250ms, auto-pause sur fired events). Pas de boutons +1j/+3j —
l'horloge tourne vraiment.
- DemoClock (top-right, fixed) : date pleine en font display, rail
rubis-glow avec pastille ◆ qui glisse vers le prochain event,
play/pause + sélecteur 1x/2x/5x. Auto-cachée si demoMode=false.
- DemoEmailSlide : slide-over droite quand event fires — affiche
l'email capturé (de/à/sujet/body) façon vrai client mail. Pause
forcée tant que tous les events ne sont pas acquittés ("comme si
le temps était vraiment passé").
- DemoToggle dans /parametres : démarrer/quitter le mode démo, avec
copy explicite ("emails capturés, pas envoyés à de vrais clients").
Le code démo vit isolé dans services/demo/, controllers/demo_controller.ts,
components/demo/, lib/demo.ts. La prod ne référence ces fichiers QUE
via captureEmailIfDemo dans mail_dispatcher.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quand un terme métier apparaît (DSO moyen, LME, mise en demeure…), un
petit astérisque rubis à côté indique qu'il est hoverable. Au hover/focus
clavier, une popover s'affiche avec la définition courte (qui ce fait,
pourquoi ça compte, repère LME 30j).
Implémentation :
- components/ui/GlossaryTerm.tsx : wrap n'importe quel ReactNode + définition,
utilise @radix-ui/react-tooltip (déjà dans la stack pour Dialog), Asterisk
Lucide en marker, underline pointillée subtile pour signaler "interactif"
- lib/glossary.tsx : définitions centralisées (DSO, LME, mise en demeure,
encaissé, rubis) — single source of truth, ton produit cohérent
- KpiCard.label / SummaryCard.label passent à React.ReactNode pour
accepter le wrapping
- Wiring : "DSO moyen" sur dashboard (KpiCard + titre du chart) et /insights
(récap + titre du chart). LME aussi taggée dans le sous-titre du DSO chart.
Aucun nouveau dep.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend
- Service dashboard.ts : computeTimeseries + computeClientTimeseries
(helper fetchPaidByMonth DRY entre les deux). Buckets pré-créés sur
N mois pour pas afficher de "trous" quand un mois n'a aucun paiement.
- GET /dashboard/timeseries?range=3|6|12 (paidByMonth + pipelineByStatus)
- GET /clients/:id/timeseries?range=3|6|12 (paidByMonth filtré)
Frontend — Recharts (43 deps, ~50KB gzip)
- components/charts/theme.ts : palette stricte (rubis + neutres chauds,
pas de bleu/vert), couleurs statuts cohérentes avec les badges côté
liste, format fr-FR pour les axes/tooltips
- ChartTooltip themed : carte cream + bordure rubis-glow, font Inter,
tabular-nums, série label override
- EncaisseChart (area, dégradé rubis-glow → transparent)
- DsoTrendChart (line ink + référence pointillée à 30j = norme LME)
- PipelineChart (donut avec total au centre + PipelineLegend séparée)
- ClientPaidChart (bar chart compact pour fiche client)
Wiring
- Dashboard / : encaissé + DSO côte à côte, pipeline + top retards en dessous
- Fiche client /clients/:id : mini bar chart "encaissés sur 6 mois" entre
les stats et la liste factures
- Page /insights : version pleine largeur des 3 charts + range selector
3m/6m/12m + 3 cards récap (encaissé total, factures payées, DSO moyen).
Lien "Insights" ajouté au sidebar desktop (icône TrendingUp).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Factories réutilisables (database/factories.ts) :
- makeClient — un client à partir de 8 templates FR (Boulangerie Martin,
Maçonnerie Dupont, etc.) avec contact/SIRET/adresse réalistes
- makeInvoice — une facture avec status driving les dates et le rubis
earned (pending = future, in_relance = échue récente, paid = paidAt
cohérent, etc.)
- makeActivityForInvoice — events alignés sur le statut (import/relance/paid)
- seedDemoOrg — recette V1 : 8 clients + 15 factures réparties sur 5
statuts (5 paid sur 6 mois, 4 in_relance, 2 awaiting_user_confirmation,
3 pending, 1 litigation) → fait vivre dashboard, factures et DSO
Commande Ace seed:demo
- Args : --email <email> (obligatoire), --reset (wipe avant), --orgName
- Flow : trouve user, configure son org (nom + bucket), provision les
4 plans par défaut (idempotent), seed la data, met à jour rubis_count
- Pose une signature email par défaut sur le user si vide
- Tout en transaction : pas d'état inconsistant si une étape plante
Usage :
node ace seed:demo --email arthurbarre.js@gmail.com
node ace seed:demo --email ... --reset --orgName="Maçonnerie Dupont"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pourquoi ce n'était pas visible jusqu'à maintenant :
PdfPreview était un placeholder V1 (barres rubis-glow + nom du fichier).
Le commentaire dans le composant le disait explicitement : "Le vrai
render PDF arrivera quand le backend stockera réellement les fichiers
dans MinIO." Le backend stocke bien les uploads (cf. drive.use().putStream
dans ImportBatches.upload, storageKey dans la table import_drafts), mais
on n'avait jamais wiré l'endpoint de streaming ni le viewer côté SPA.
Backend
- GET /invoices/import-batch/:id/drafts/:draftId/pdf : stream le binaire
depuis MinIO via Drive, content-type adapté (PDF/PNG/JPG), Cache-Control
privé 5min, Content-Disposition inline pour permettre le rendu dans
un <iframe>/<embed> sans téléchargement forcé. Auth Bearer (vérification
d'org via loadBatchOrFail).
Frontend
- api.fetchBlob() : helper pour fetch binaires avec Bearer auto-injecté
(le JSON-only existant ne marche pas pour les PDF).
- PdfPreview accepte batchId+draftId+pdfAvailable, fetch le binaire au
mount, crée un object URL, affiche :
· <iframe> pour les PDF (viewer Chrome/Safari natif)
· <img> pour les images (PNG/JPG)
· Spinner pendant le chargement, fallback "barres" si pdfAvailable=false
(ex. mode mock MSW), erreur visible si 404 / network down
- Cleanup URL.revokeObjectURL au unmount pour pas leaker la mémoire
- pdfStorageKey ajouté au type ImportDraft côté SPA
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le détail client servait `invoices: []` en hardcoded — la liste sur
/clients/:id était toujours vide. On préload la relation client + plan
sur les factures de l'org filtrées par client_id, on trie par priorité
de statut puis échéance, et on serialize via InvoiceTransformer (même
shape que GET /invoices, donc le SPA peut réutiliser InvoiceListItem).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
En dev le SPA tourne sur :5173 et l'API sur :3333. Un href relatif
`/api/v1/auth/google/redirect` tape Vite (404). On préfixe par
`env.VITE_API_URL` (http://localhost:3333 en dev, https://app... en prod
où nginx reverse-proxy /api/* — donc l'URL est self-referential et
fonctionne dans les deux cas).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend
- Custom Ally driver Microsoft (Oauth2Driver) — Microsoft n'est pas dans
les providers built-in, mais le driver dérive de Oauth2Driver en quelques
lignes. Endpoints v2.0 (Microsoft Identity Platform), Graph /me pour le
profil, fallback userPrincipalName si mail null (comptes perso).
- Tenant configurable via MICROSOFT_TENANT (défaut 'common' — accepte
work/school + perso ; 'organizations' pour M365 strict).
- Migration 1400 : ajout microsoft_id nullable unique sur users.
- AuthMicrosoftController : redirect + callback (même pattern que Google).
- Refacto : extraction d'un service sso_session.ts (findOrCreateUserFromSso,
nextRouteAfterSso, emitSsoSessionAndRedirect) → AuthGoogle + AuthMicrosoft
partagent la logique.
- Routes /api/v1/auth/microsoft/{redirect,callback}.
Frontend
- Composant SsoButton générique (provider="google"|"microsoft") avec logo
officiel inline pour chaque. Remplace l'ancien GoogleButton.
- Login + signup : pile verticale "Continuer avec Google" + "Continuer
avec Microsoft", puis séparateur "ou", puis form email/password.
- Route SPA renommée /auth/google/complete → /auth/sso/complete (partagée
entre les deux providers, la callback API redirige toujours dessus).
- Erreurs SSO sur /login : ?google=... ET ?microsoft=... → toast contextuel.
K3s
- ConfigMap rubis-api-config : ajout MICROSOFT_TENANT + MICROSOFT_CALLBACK_URL.
- Secret rubis-app-secrets : ajout MICROSOFT_CLIENT_ID + MICROSOFT_CLIENT_SECRET.
Doc
- .claude/deploy-memory.md : procédure Azure / Entra ID app registration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend
- @adonisjs/ally installé + provider Google configuré (config/ally.ts)
scopes: userinfo.email + userinfo.profile (non-sensibles, validation
auto par Google)
- Migration : ajoute google_id (nullable unique) sur users + rend password
nullable (un user créé via Google n'a pas de mdp en base, il pourra
l'activer plus tard via "mot de passe oublié")
- AuthGoogleController.redirect : entrée OAuth (le bouton SPA pointe ici)
- AuthGoogleController.callback : matche par google_id puis email,
crée org+plans+user si nouveau, pose le refresh cookie httpOnly,
redirige le browser vers le SPA /auth/google/complete?next=...
(next = / pour user complet, /onboarding/entreprise pour nouveau)
- Routes : GET /api/v1/auth/google/{redirect,callback}
- Env : GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URL
Frontend
- Composant GoogleButton réutilisable (full-page redirect, pas fetch —
OAuth nécessite navigation pour les cookies cross-origin Google)
- AuthDivider "ou" entre SSO et formulaire email/password
- Boutons ajoutés sur /login et /signup
- Route /auth/google/complete : appelle POST /api/v1/auth/refresh (le
cookie posé par la callback est auto-envoyé), stocke access token +
user dans authStore, navigue vers `next`. Échec → /login + toast.
- Toast d'erreur sur /login si on revient avec ?google=denied|error|...
K3s
- ConfigMap rubis-api-config : ajout GOOGLE_CALLBACK_URL prod
- Secret rubis-app-secrets : ajout GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
(posés via kubectl, pas dans le manifest)
Doc
- .claude/deploy-memory.md mis à jour avec la procédure Google Cloud
Console (créer OAuth client, redirect URIs, écran de consentement)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le bundle Vite plantait au boot avec :
Variables d'environnement invalides : { VITE_API_URL: ..., VITE_PUBLIC_LANDING_URL: ... }
Vite remplace import.meta.env.VITE_* par des literals au build time
(pas au runtime), donc l'image doit recevoir ces vars AVANT vite build.
Le Dockerfile.web accepte maintenant 3 ARGs :
- VITE_API_URL (default: https://app.rubis.arthurbarre.fr)
- VITE_PUBLIC_LANDING_URL (default: https://rubis.arthurbarre.fr)
- VITE_USE_MOCKS (default: false)
Le workflow CI les passe explicitement via build-args pour lisibilité.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Avant : une seule image (Dockerfile.app) qui bundle AdonisJS + SPA static.
Après : deux images, deux deployments, deux workflows CI avec path filters
indépendants.
Architecture
- rubis-web (NodePort 30110, exposé via Traefik)
· nginx-alpine + SPA Vite dist + nginx.conf
· sert /assets/* (cache 1y immutable), / (try_files index.html SPA fallback)
· reverse-proxy /api/* → rubis-api.rubis.svc.cluster.local:3333
- rubis-api (ClusterIP, accessible uniquement depuis le cluster)
· AdonisJS V7 + workers BullMQ dans le même process
· init-container migrate (idempotent, depuis build/)
· /api/v1/health pour les probes K3s + healthcheck Docker
- rubis-redis (ClusterIP, inchangé)
Bénéfices
- Build/deploy indépendants : changement front ne reconstruit pas l'API,
changement API ne reconstruit pas le SPA
- nginx en frontal donne du gzip + cache long sur les assets fingerprintés
- API n'expose plus de surface publique (defense in depth)
- Routes plus simples : on retire le wildcard SPA fallback dans
start/routes.ts (nginx s'en charge), on retire @adonisjs/static aurait
été cohérent mais on le garde pour minimiser les diffs
Files
- Dockerfile.api (replaces Dockerfile.app, Node-only)
- Dockerfile.web (new, nginx)
- apps/web/nginx.conf (new)
- k3s/app/api.yml (replaces deployment.yml + service.yml, ClusterIP)
- k3s/app/web.yml (new, NodePort 30110)
- .gitea/workflows/deploy-{api,web}.yml (replaces deploy-app.yml)
- /api/v1/health route ajoutée
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le pod plantait en Init:CrashLoopBackOff parce que le init container
tournait depuis /app/apps/api avec \`node ace\` — qui charge le shim
ace.js → bin/console.ts (TS source). Sans devDeps en runtime, pas de
loader TS → ERR_UNKNOWN_FILE_EXTENSION.
Fix : workingDir /app/apps/api/build + command \`node ace.js
migration:run --force\`. build/ contient les .js compilés.
Memory mise à jour pour documenter ce piège.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le CI plantait avec ERR_UNKNOWN_FILE_EXTENSION sur bin/console.ts —
le hook @poppinss/ts-exec (basé sur @swc/core) ne s'enregistre pas
à temps avant l'import du premier .ts dans certains environnements
de build (vu local M-series ET CI Gitea linux/amd64).
Solution : appeler ace via tsx (esbuild-based, gère nativement .ts
dès le démarrage). Plus fiable cross-platform.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Premier déploiement de l'app SaaS (apps/api + apps/web) — distinct de la
landing déjà sur rubis.arthurbarre.fr. Architecture :
- Image unique (Dockerfile.app, multi-stage) : AdonisJS sert l'API ET le
SPA static via @adonisjs/static + wildcard fallback pour TanStack Router
- Workers BullMQ tournent dans le même process Node (cf. start/queue.ts)
- Redis 7 dans le namespace rubis (PVC local-path 1Gi)
- Migrations en init-container avant le serveur (idempotent)
Infra :
- K3s namespace rubis (déjà existant) — ajout deploy/svc rubis-app + redis
- NodePort 30110 → Traefik → app.rubis.arthurbarre.fr (TLS Let's Encrypt)
- Postgres : base rubis_prod + user rubis créés sur 10.10.10.3
- MinIO : bucket rubis-prod-invoices créé via mc
- Secrets K3s posés via kubectl create secret (APP_KEY généré, DB pwd
généré, MinIO root creds réutilisées, Resend/Mistral keys)
- DNS OVH A record app.rubis créé (id 5413305619)
- CI Gitea : .gitea/workflows/deploy-app.yml séparé du workflow landing,
filtres sur paths apps/**, packages/**, Dockerfile.app, k3s/app/**
Code app :
- Static middleware @adonisjs/static configuré
- Wildcard route SPA fallback en fin de routes.ts
- Fix erreurs strict TS qui bloquaient le build vite (unused vars,
Client missing contactFirstName/LastName dans MSW)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tailwind v4 ne pose plus cursor:pointer sur <button> par défaut, ce qui
rendait l'app un peu morte au survol. Plutôt que d'ajouter cursor-pointer
sur chaque composant, on le pose une fois pour toutes en CSS de base sur :
- button, role="button", a[href], summary, label[for], select
- inputs cliquables (submit, button, reset, checkbox, radio)
Les éléments désactivés (disabled, aria-disabled) basculent en
cursor:not-allowed pour signaler clairement l'état.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bugs remontés sur les générations IA :
- Le modèle utilisait `{{#var}}...{{/var}}` (sections Mustache) pour
gérer les fallbacks de prénom — notre interpréteur ne fait que de
la substitution simple, donc le charabia s'affichait dans l'email.
- La signature était dupliquée : l'IA écrivait le nom à la main puis
ajoutait `{{signature}}`.
- Le contexte du plan (nom + description) n'était pas transmis, donc
les générations étaient déconnectées du sens du plan parent.
Corrections du SYSTEM_PROMPT :
- Section "Syntaxe des variables" explicite : substitution simple
uniquement, INTERDICTION des `{{#...}}` / `{{^...}}` / conditionnels
- Section "Tu n'es PAS obligé d'utiliser toutes les variables"
→ l'IA pioche celles qui rendent le message naturel
- Règle : terminer toujours par {{signature}} sur sa propre ligne,
ne JAMAIS réécrire le nom de l'expéditeur après (la variable
contient déjà nom + entreprise + formule de politesse)
Backend
- ai_relance_generator : type GenerateRelanceInput accepte planName
+ planDescription (à la place de l'ancien planContext fourre-tout)
- user message structuré en sections # Plan parent / # Cette relance
/ # Brief de l'utilisateur, plus lisible pour le modèle
- ai_controller validator : accepte planName + planDescription
Frontend
- AiGenerateModal accepte planName + planDescription en props et
les passe à l'API
- Affiche le nom du plan dans la description de la modale
- Bloc dépliable "Variables que l'IA peut insérer (sans obligation)"
pour montrer à l'utilisateur ce qui est dispo
- StepMessages passe draft.name + draft.description au modal
- MSW handler aligné sur le nouveau contrat
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Champ Décalage retiré : on change le timing en cliquant une autre
case du calendrier (delete + click ailleurs), c'est plus aligné
avec la métaphore calendrier
- Tonalité passée d'un select à un groupe de 4 boutons icônes :
· Doux → Smile (sourire chaleureux)
· Standard → MessageCircle (bulle de conversation polie)
· Ferme → AlertTriangle (alerte mesurée)
· Strict → Gavel (marteau de juge)
Chaque bouton actif prend la couleur de fond de sa tonalité, plus
visuel et compact qu'un dropdown
- Header de l'éditeur : la pastille colorée devient une pastille avec
l'icône de tonalité dedans → on lit la tonalité d'un coup d'œil
- Toggle : re-cliquer la case déjà sélectionnée la désélectionne
(retour à l'état "vue d'ensemble" avec le hint), au lieu d'avoir
une sélection permanente
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Issues remontées :
- Cellules étirées en horizontal sur desktop (rectangles plats)
- Échéance et "Doux" indiscernables (tous deux en rubis-glow)
- Pas de feedback au clic, "Étape 1" déjà affichée par défaut sans
qu'on l'ait sélectionnée
- "Étape 1 · 20 juin · J+5" pas parlant
- Cliquer sur une case vide ne faisait rien
Refonte :
- Calendrier max-w-md mx-auto + cells aspect-square → carrés équilibrés
- Échéance = bg-rubis solide (pas glow) + ◆ blanc + ombre rubis →
visuellement distincte de toutes les tonalités
- Cellule étape = couleur tonalité + badge "J+X" en coin haut-droit
- Sélection forte : ring-4 + scale-1.08 + shadow-rubis-hover sur la
case sélectionnée → impossible de la rater
- Default selectedIdx = -1 (pas de présélection) → hint clair :
"Touchez une case colorée pour modifier, ou un jour vide pour ajouter"
- **Click sur case vide → crée une étape à cet offset**, triée par
ordre temporel (insertion smart, pas en bout). Plus l'usage le plus
naturel de l'outil : "je veux relancer le 5 juin" → clic.
- Click sur échéance → toast explicatif (pas une no-op silencieuse)
- Header de l'éditeur : "Relance du **5 juin** · J-10" (pas "Étape 1")
- Hover sur jour vide : "+" rubis apparaît → affordance d'ajout claire
- Hors plage [-30, +180] ou >= 8 étapes : cellule disabled, toast info
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le calendrier précédent prenait toute la page (1-2 mois empilés en
pleine taille). Refonte en mini-calendrier de navigation :
- Un seul mois affiché à la fois, navigation prev/next via chevrons
- Auto-jump au mois de l'étape sélectionnée pour ne jamais perdre
la cellule de vue
- Cellules h-9 fixe (plus de aspect-square qui gonflait sur écran large)
- Header compact : juste mois + chevrons (pas de gros titre)
- Légende inline une ligne ("◆ Échéance le X · couleur = tonalité")
- Éditeur compact en dessous : 1 ligne d'en-tête (◆ tonalité · étape N
· 18 mai · J+3) + 1 ligne 2-cols (input offset + select tonalité +
bouton supprimer en icône). Plus de Field / hint volumineux.
- Footer : bouton Ajouter en pleine largeur (sauf compteur 3/8 à droite)
Hauteur totale ~400px en pratique vs 700px+ avant.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remplace la liste verticale par une vraie visu calendrier qui ancre
chaque étape sur une date concrète, ce qui donne du sens au timing.
- Date d'échéance fictive : le 15 du mois prochain (stable, prévisible,
laisse de la marge avant/après pour offsets négatifs comme positifs)
- Cellule échéance = ◆ rubis plein sur fond rubis-glow + shadow rubis,
jour mis en exergue
- Cellule étape = couleur de fond pleine selon la tonalité (Doux =
rubis-glow, Standard = cream-2, Ferme = ink, Strict = rubis-deep)
avec affichage J+X / numéro du jour
- Cellule jour normal = numéro muted, today souligné en rubis-glow
- Click sur cellule étape → sélection, l'éditeur (offset, ton,
supprimer) apparaît directement sous le calendrier
- Légende des tonalités juste sous l'en-tête
- Affiche tous les mois entre la 1re et la dernière étape (échéance
incluse) — typiquement 1 à 2 mois en pratique
- Mêmes raccourcis qu'avant : OffsetInput string-controlled qui accepte
les états intermédiaires "" et "-"
Suppression de CadenceTimeline.tsx (la liste verticale précédente).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>