29 Commits

Author SHA1 Message Date
ordinarthur
77fdb6af48 feat: email de remerciement automatique après confirmation de paiement
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m43s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m16s
Quand l'utilisateur confirme « Oui, payé » via check-in (lien email ou modale
in-app) ou marque une facture encaissée manuellement, on envoie automatiquement
un email de remerciement chaleureux au client final. Subject + body éditables
par plan (mêmes variables que les relances), avec fallback hardcodé si vide.
Gardé par la transition `* → paid` pour idempotence ; envoi async via BullMQ
avec retry exponentiel ; capture en mode démo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 16:41:26 +02:00
ordinarthur
e5530930b3 feat: refactor frontend en stack React unifiée (Astro + packages/ui)
Some checks failed
Build & Deploy API / build-and-deploy (push) Failing after 17s
Build & Deploy Web / build-and-deploy (push) Successful in 1m15s
Build & Deploy Landing / build-and-deploy (push) Failing after 3m43s
Trois surfaces partagent désormais le même design system, Tailwind v4
et React 19 — au lieu d'avoir landing en HTML vanilla, app en React, et
blog en Adonis SSR :

* packages/ui — design system partagé (tokens Tailwind v4 + composants
  TSX) extrait depuis apps/web : Brand, Gem, Button, Card, Chip, Eyebrow,
  EmptyState. apps/web migre 41 imports vers @rubis/ui.

* apps/landing — nouvelle app Astro 6 SSR (rubis.pro), remplace l'ancienne
  landing nginx vanilla. Embarque :
  - Landing complète portée en sections React (Hero, Stats, Promise,
    HowItWorks, Gamification, Legal, Pricing, FAQ, FinalCTA, Footnotes)
  - Pages légales (mentions, confidentialité, CGV) via LegalLayout.astro
  - Blog SSR (/blog, /blog/:slug) qui consomme /api/v1/posts
  - sitemap.xml, blog/rss.xml, robots.txt en endpoints Astro
  - SEO complet (canonical, hreflang, OG, Twitter Card, JSON-LD
    Article/BreadcrumbList/Blog/SoftwareApplication)

* apps/api — BlogController réduit à 2 endpoints JSON (GET /api/v1/posts
  + GET /api/v1/posts/:slug). Suppression des templates SSR Adonis
  (apps/api/app/blog/), de l'alias #blog/*, des deps react-dom et
  @types/react-dom. PostTransformer + PostSummaryTransformer ajoutés.
  Le service blog_renderer + le seeder + les 3 articles fondateurs
  restent intacts (réutilisés par futurs admin + cron IA).

* Infra :
  - Dockerfile.landing (multi-stage Node 22 + tini, Astro standalone)
  - k3s/app/landing.yml (Deployment + Service rubis-landing:4321 +
    ConfigMap avec API_URL=http://rubis-api.rubis.svc.cluster.local:3333)
  - .gitea/workflows/deploy.yml mis à jour pour build rubis-landing
  - .gitea/workflows/deploy-web.yml + Dockerfile.web : prennent en
    compte packages/ui/ comme dépendance
  - Suppression du Dockerfile nginx legacy + k3s/{deployment,service}.yml
  - Suppression de landing/ (assets favicons migrés vers
    apps/landing/public/)

* Docs : architecture.md (vue d'ensemble + §4bis apps/landing complet,
  §3 endpoints JSON blog, layout monorepo), CLAUDE.md (stack technique,
  documents associés, déploiement).

Note infra : l'ancien Deployment "rubis" (nginx) et son Service ne sont
PAS supprimés par la CI — à nettoyer manuellement après validation que
Traefik a été repointé sur rubis-landing:4321 dans le repo proxmox.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:09:13 +02:00
ordinarthur
7c45ee4490 add plausible
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy Landing / build-and-deploy (push) Successful in 17s
Build & Deploy API / build-and-deploy (push) Successful in 1m20s
2026-05-08 13:08:07 +02:00
ordinarthur
1c5a58e09a chore(domain): migrate rubis.arthurbarre.fr → rubis.pro
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 26s
Build & Deploy Landing / build-and-deploy (push) Successful in 27s
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
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>
2026-05-07 21:32:31 +02:00
ordinarthur
ff8fe64be2 feat(mail): templates HTML React Email + brand "Rubis sur l'ongle"
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m1s
Build & Deploy API / build-and-deploy (push) Successful in 2m3s
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>
2026-05-07 18:10:27 +02:00
ordinarthur
87c6f49692 fix(mail): from-name = nom de l'org (pas "Rubis Sur l'Ongle")
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m8s
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>
2026-05-07 17:45:57 +02:00
ordinarthur
cb87bbc8d1 feat(billing): expose l'annulation programmée + bouton "Réactiver"
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 27s
Build & Deploy API / build-and-deploy (push) Successful in 1m14s
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>
2026-05-07 17:05:02 +02:00
ordinarthur
1952265217 feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m0s
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy API / build-and-deploy (push) Successful in 1m52s
Pricing V1 :
  - Free  : 5 factures actives, 1 user, 3 mois de grâce illimité au signup
  - Pro   : 19 €/mois ou 190 €/an, factures illimitées, 1 user
  - Business : 49 €/mois ou 490 €/an, illimité + 5 sièges (V2 multi-users)
              + reply-from-user-email (V2)

Backend :
  - Migration : plan, grace_period_ends_at, stripe_customer_id,
    stripe_subscription_id, subscription_status, billing_cycle,
    current_period_end sur `organizations`. Backfill grace_period auto.
  - `app/services/billing.ts` : PLAN_CAPS, countActiveInvoices,
    canCreateInvoices (enforce post-grace), getOrgSubscriptionState.
  - `app/services/stripe.ts` : client lazy + lookup_keys stables.
  - `app/controllers/billing_controller.ts` :
      • GET  /billing/subscription      → state pour l'UI
      • POST /billing/checkout          → crée une Checkout Session
      • POST /billing/portal            → Customer Portal Session
      • POST /billing/webhook (public)  → handle 4 events Stripe
        (checkout.completed, subscription.updated/deleted, invoice.payment_failed)
  - `commands/stripe_setup.ts` : `node ace stripe:setup` crée Products +
    Prices (idempotent via lookup_key).
  - Enforcement 402 `plan_limit_reached` sur :
      • POST /invoices (saisie manuelle)
      • POST /invoices/import-batch/:id/drafts/:draftId/validate (OCR)

Frontend :
  - `lib/billing.ts` : useSubscription, useStartCheckout, useOpenPortal,
    useIsAtFreeLimit.
  - `routes/_app/parametres_.abonnement.tsx` : page comparaison plans
    avec toggle mensuel/annuel, current plan + portail Stripe, CTA upgrade
    qui redirige vers Checkout hostée.
  - `routes/_app/parametres.tsx` : nouvelle section "Abonnement" qui
    affiche le plan courant + lien vers la page abonnement.
  - `components/billing/PlanLimitBanner.tsx` : banner sur /factures qui
    s'adapte selon période (grâce / approche / atteinte).
  - Toast dédié 402 sur la validation OCR avec action "Passer Pro".

Doc :
  - flow.md : nouvelle section §11 "Pricing & enforcement" qui couvre
    plans, grâce, webhook flow, Customer Portal, env vars.

Setup dev :
  1. STRIPE_SECRET_KEY (sk_test_...) dans apps/api/.env
  2. `stripe listen --forward-to localhost:3333/api/v1/billing/webhook`
     → copier whsec_... → STRIPE_WEBHOOK_SECRET
  3. `node ace stripe:setup` une fois pour créer Products+Prices
  4. Tester via /parametres/abonnement → checkout en mode test Stripe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:03:28 +02:00
ordinarthur
68ed8f2ec6 feat(api): logs fichier en dev + traces du flow relance/mail
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
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>
2026-05-07 12:37:20 +02:00
ordinarthur
89c9a732d6 add chart details
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
2026-05-07 11:42:36 +02:00
ordinarthur
933c6496b1 feat(demo): mode démo live — horloge virtuelle + emails capturés
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>
2026-05-07 10:42:59 +02:00
ordinarthur
2d3766cc3d feat(dashboard): dataviz cohérente DA Rubis (3 charts + page Insights)
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>
2026-05-07 10:11:45 +02:00
ordinarthur
7521e1fff6 feat(auth): Microsoft 365 SSO + factorisation helper SSO partagé
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>
2026-05-07 09:38:38 +02:00
ordinarthur
5c7dbc2eba fix(plans/ai): contexte plan + interdiction Mustache sections
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>
2026-05-06 23:48:57 +02:00
ordinarthur
9e531e32a9 feat(plans): wizard de création de plan custom + génération IA Mistral
Backend
- migration : champs contact_first_name / contact_last_name (nullable)
  sur clients pour personnaliser les variables de relance
- POST /api/v1/plans : création de plan custom avec slug auto-généré
  (suffixé en cas de collision, "nouveau"/"new"/"create" réservés)
- POST /api/v1/ai/generate-relance : génération de subject+body via
  mistral-small-latest, avec brief utilisateur et tonalité ciblée
- mail_dispatcher : nouvelles variables {{daysLate}}, {{issueDate}},
  {{user.fullName}}, {{user.companyName}}, {{client.contactFirstName}},
  {{client.contactLastName}} (helper buildRelanceVars exposé pour preview)
- send_relance_job preload désormais l'organization pour exposer son name

Frontend
- /plans/nouveau : wizard 4 étapes (Identité → Cadence → Messages → Récap)
  - Stepper en haut, navigation guidée, validation par étape
  - Étape 1 : nom + tonalité globale (4 cards Doux/Standard/Ferme/Strict)
    avec aperçu de la cadence par défaut associée
  - Étape 2 : timeline horizontale (rail rubis-glow + nœuds ◆ teintés
    selon la tonalité), édition décalage/ton de l'étape sélectionnée
  - Étape 3 : édition par étape avec preview live à droite, chips de
    variables cliquables, bouton "Générer avec l'IA" qui ouvre une modale
    Mistral (brief + résultat + régénérer)
  - Étape 4 : récap avec preview de chaque email rendu sur un client fictif
- Détection des variables sensibles → warning si X clients existants n'ont
  pas le champ contactFirstName/contactLastName rempli (UX informative,
  fallback vide à l'envoi)
- "Dupliquer" sur chaque card de plan → /plans/nouveau?from=<slug>
  pour pré-remplir le wizard à partir d'un plan existant
- ClientCreateDialog : ajout des champs prénom/nom du contact dédié
- TEMPLATE_VARIABLES étendu, helper renderTemplate côté front en miroir
  exact de l'implémentation API
- MSW handlers ai/plans/clients alignés sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:55:00 +02:00
ordinarthur
a790455ae1 feat(api): bascule des envois mail sur Resend (fin de Mailpit en dev)
- MAIL_DRIVER=resend par défaut, from rubis@arthurbarre.fr (domaine vérifié)
- replyTo posé sur user.email dans les relances : les réponses des clients
  reviennent au patron de la TPE, pas dans notre boîte transactionnelle
- ajout d'une commande Ace `send:test-email` pour valider la conf
  (driver, from, SPF/DKIM/clé API) sans passer par tout le flow facture

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:22:33 +02:00
ordinarthur
b8dec6d494 update la relance par mail 2026-05-06 19:02:39 +02:00
ordinarthur
5e41e2a9fa add ocr + add factures 2026-05-06 18:47:35 +02:00
ordinarthur
fc66d80f56 test(api): setup Japa + tests fonctionnels auth (signup/login/logout/onboarding)
Setup :
- .env.test étoffé : DRIVE_DISK=fs, MAIL_DRIVER=smtp local, OCR_PROVIDER=mock. Réutilise la DB rubis_dev avec global transactions par test (rollback auto, isolation parfaite).
- Schedulers (relance + checkin) détectent NODE_ENV=test et skippent BullMQ.add. Les tasks DB sont quand même créées (utiles pour assertions) mais aucun job orphelin n'arrive en Redis après rollback de tx.
- helpers/auth.ts : factory createTestUser() qui crée org + user + 4 plans pré-fournis dans une tx, retourne user/org/accessToken/bearer header. createTwoOrgs() pour les tests cross-org à venir.

Tests fonctionnels auth (tests/functional/auth.spec.ts) :
- Signup : crée user + org + 4 plans pré-fournis (vérifie les slugs), refuse email mal formé / password court / email déjà pris
- Login : émet AuthSession avec credentials valides, rejette mauvais password / email inconnu
- Bearer auth : 401 sans token, 401 avec token bidon, 200 avec token valide
- Logout : révoque le token courant, requêtes suivantes en 401
- Onboarding : PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom, idempotent ensuite

Pour lancer : `pnpm -F api test`
2026-05-06 15:45:11 +02:00
ordinarthur
299f7beb63 fix(api): swap ':' → '-' dans les BullMQ jobIds (interdit en 5.x)
BullMQ 5+ refuse les ':' dans les Custom Ids (validateOptions throws "Custom Id cannot contain :"). On utilisait `relance:<taskId>` et `checkin:<taskId>` pour assurer l'idempotence — passe en `relance-<taskId>` / `checkin-<taskId>`.
2026-05-06 15:37:03 +02:00
ordinarthur
94263c6447 feat(api): check-in flow — email à l'user + endpoints publics paid/pending
Le check-in remplace l'intégration banking V1 (cf. CLAUDE.md → Glossaire) :
avant que la 1re relance ne parte, on demande à l'user "as-tu été payé ?"
via email, et il clique sur l'un des 2 liens publics.

Service checkin_token.ts : génération + hash SHA-256. 32 bytes random base64url, plain dans le mail, hash en DB (CheckinTask.token_hash unique).

Service checkin_scheduler.ts :
- scheduleCheckinForInvoice(invoice) : crée 1 CheckinTask à dueDate (now+1min si dueDate dans le passé). Idempotent par invoice — cancel les scheduled précédents avant.
- cancelCheckinForInvoice(invoiceId) : appelé par mark-paid pour stopper.

Job send_checkin_job.ts : worker queue 'checkins', skip si invoice paid/cancelled (no-op), construit l'URL avec le plain token (passé dans le payload du job, pas relu DB), appelle sendCheckinEmail.

mail_dispatcher.ts : sendCheckinEmail() — texte brut, destinataire = user (pas client !), 2 URLs (paid / pending), TTL 24h annoncé.

Controller CheckinController :
- GET /api/v1/checkin/:token/paid : status=answered + answer=paid + mark invoice paid (mêmes effets que POST /invoices/:id/mark-paid : rubis +1, ActivityEvent invoice_paid avec label "via check-in", cancelFutureRelances). Idempotent : 2e click → redirect "already_answered".
- GET /api/v1/checkin/:token/pending : status=answered + answer=still_pending. Les relances suivent leur cours.
- Validation : lookup hash, expiry (sentAt + 24h), redirects propres pour invalid / expired / already_answered.

Routes : nouveau group public `checkin` (PAS de middleware.auth) à côté du group auth, sous /api/v1.

Triggers branchés :
- InvoicesController.store et ImportBatchesController.validateDraft → scheduleCheckinForInvoice après création
- InvoicesController.markPaid → cancelCheckinForInvoice dans la tx

start/queue.ts : registerWorker('checkins', sendCheckinJob).

env : nouveau WEB_URL (URL du SPA pour redirects), default localhost:5173 en dev.

Bruno : nouveau dossier 08-Checkin avec doc complète du flow + 2 requêtes (paid / pending). var d'env `checkinToken` à remplir manuellement après avoir reçu l'email dans Mailpit.
2026-05-06 15:31:40 +02:00
ordinarthur
a6b35dfe7a feat(api): RelanceTask + CheckinTask + worker BullMQ qui envoie les relances
Migrations :
- relance_tasks (uuid id, organization_id FK CASCADE [scope direct sans join], invoice_id FK CASCADE, plan_step_id FK RESTRICT, send_at, status ENUM scheduled/sent/cancelled/failed, sent_at, queue_job_id pour cancel via BullMQ.remove). Indexes (org,status), (invoice_id), (send_at).
- checkin_tasks (uuid id, org_id, invoice_id, send_at, token_hash unique [SHA-256 du HMAC, TTL 24h], status ENUM scheduled/sent/answered/expired, answer 'paid'|'still_pending'). Pas encore branché — flow check-in arrivera dans un commit séparé (cf. backend.md §13.3).

Schema rules : status enums + answer typés.

Models RelanceTask + CheckinTask avec belongsTo Invoice / PlanStep.

Service relance_scheduler.ts :
- scheduleRelancesForInvoice(invoice) : pour chaque step du plan, calcule sendAt = dueDate + offsetDays. Si sendAt < now (facture importée en retard), on programme à `now + 1min` plutôt que skip — l'utilisateur "rattrape" une dette de relance, l'envoi immédiat est cohérent. Crée la RelanceTask + enqueue BullMQ avec delay, retry 5x exponential, jobId = `relance:<taskId>` pour idempotency. Cancelle les tasks scheduled existantes avant de re-programmer (gestion changement de plan).
- cancelFutureRelances(invoiceId, trx) : appelé par mark-paid pour stopper la chaîne.

Service queue.ts :
- getQueue(name) singleton lazy par queue
- registerWorker(name, handler) avec concurrency 5, log failed/completed
- shutdownQueue() pour le terminating hook Adonis

start/queue.ts (preload) : registerWorker('relances', sendRelanceJob) seulement quand `app.getEnvironment() === 'web'` (pas en tests/REPL — pas de connexion Redis pendant Japa).

Job send_relance_job.ts :
- Idempotent : si task.status !== 'scheduled', no-op
- Hook critique : si invoice paid/cancelled entre-temps, task.status = cancelled
- Mise en demeure (step.requiresManualValidation) : on n'envoie PAS, on log un activity_event 'warning_drafted' (cf. CLAUDE.md → Principes : validation manuelle obligatoire)
- Sinon : sendRelanceEmail + task.status=sent + invoice.rubisEarned+1 + organizations.rubis_count+1 + activity_event 'relance_sent'. Si invoice.status='pending', passe en 'in_relance' (sortie de l'état silencieux).

Service mail_dispatcher.ts : sendRelanceEmail interpole step.subject/body via mini moteur Mustache-like (renderTemplate, services/template.ts) avec {{client.name}}/{{numero}}/{{amount}}/{{dueDate}}/{{signature}}, puis @adonisjs/mail.use(MAIL_DRIVER) → Mailpit en dev, Resend en prod. Texte brut V1.

Triggers branchés :
- InvoicesController.store : si planId, scheduleRelancesForInvoice après création
- ImportBatchesController.validateDraft : pareil
- InvoicesController.markPaid : cancelFutureRelances dans la même tx que le paiement

#jobs/* ajouté aux imports package.json. Adonisrc preload start/queue.ts.

Bruno : doc 05-Invoices/04 Create maj avec instructions pour tester l'envoi immédiat (dueDate dans le passé → relance à now+1min → email visible dans Mailpit http://localhost:8025).
2026-05-06 15:24:46 +02:00
ordinarthur
19dd71bd93 feat(api): MistralOcrProvider + multipart upload sur /invoices/upload
MistralOcrProvider (app/services/ocr/mistral_ocr_provider.ts) :
- Pipeline 2 étapes : POST /v1/ocr (mistral-ocr-latest) → markdown structuré, puis POST /v1/chat/completions (mistral-large-latest) avec response_format json_schema strict pour extraire les champs typés (clientName/Email, numero, amountTtcCents, issueDate, dueDate) + un objet `_conf` pour la confiance par champ.
- Télécharge le PDF depuis Drive (MinIO en dev) via getArrayBuffer, encode en base64 pour le data URI.
- Throw clair si storageKey null (incompatible avec le mode JSON {filenames}).
- Throw au constructor si MISTRAL_API_KEY manquante.

getOcrProvider() retourne maintenant vraiment Mistral quand OCR_PROVIDER=mistral (plus de fallback silencieux sur mock).

Multipart upload sur POST /invoices/upload :
- Détecte Content-Type. Si multipart/form-data : itère sur `files[]`, valide ext (pdf/png/jpg/jpeg) + size (10mb), upload chaque fichier vers `import-drafts/<orgId>/<draftId>.<ext>` via @adonisjs/drive, puis appelle createImportBatch avec sources [{filename, storageKey}].
- Si JSON : route compat conservée pour le mode démo.

Refactor service import_batch :
- Nouvelle fonction createImportBatch(orgId, sources) générique
- createImportBatchFromFilenames() devient un wrapper compat (storageKey null)
- OCR exécuté HORS transaction (calls réseau Mistral lents — 3-8s par PDF — pas de raison de tenir un lock PG)

Bruno :
- 06-Imports/02 Upload (multipart Mistral).bru — nouveau, body multipart-form avec @file() à sélectionner. Doc : setup .env, where to find files in MinIO console, latence Mistral.
- Renumérote 03/04/05/06 (Get batch / Validate / Skip / Cancel).
- Met à jour 01 Upload (mock) doc pour pointer vers 02 pour le vrai OCR.

Pour tester :
1. .env → OCR_PROVIDER=mistral + MISTRAL_API_KEY=...
2. Restart pnpm dev:api
3. Bruno → Imports → 02 Upload (multipart Mistral) → sélectionne un PDF
4. Bruno → Imports → 03 Get batch (drafts ont pdfStorageKey + extracted depuis l'OCR)
2026-05-06 15:17:11 +02:00
ordinarthur
704f472729 feat(api): dashboard kpis + activity feed + top-late + ActivityEvent
Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).

Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.

Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.

Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.

Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5

Routes /api/v1/dashboard/* (auth requise).

Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.

Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
2026-05-06 15:10:58 +02:00
ordinarthur
5d3408fafa feat(api): refresh tokens custom (cookie httpOnly + rotation panic-mode)
Pattern hybride (cf. backend.md §7) : access token Bearer 30min en JSON + refresh token 30j en cookie httpOnly `rubis_refresh` géré custom au-dessus d'@adonisjs/auth qui ne ship pas de primitive refresh.

Migration refresh_tokens (uuid id, user_id FK CASCADE, hashed_token unique [SHA-256, 64 chars hex], expires_at, last_used_at nullable, revoked_at nullable, ip_address, user_agent). Index user_id + expires_at.

Service refresh_token.ts :
- issueRefreshToken(user, ctx) : génère 32 bytes random → base64url → hash SHA-256 stocké, plain dans le cookie httpOnly + secure (en prod) + sameSite strict + path=/api/v1/auth (le browser n'envoie le cookie que sur les routes auth, pas chaque requête API).
- consumeRefreshToken(ctx) : lookup par hash, validation expiry/revoked. Si on présente un token DÉJÀ révoqué, panic mode : tous les refresh tokens actifs du user sont invalidés (signal de vol — le vrai propriétaire devra se re-logger).
- revokeCurrentRefreshToken / revokeAllForUser pour logout et le panic.

Service auth_session.ts : factorise emitAuthSession(user, ctx) qui crée access + refresh + retourne l'AuthSession. Utilisé par signup / login / refresh — DRY.

Controllers :
- POST /auth/signup : emitAuthSession après tx (org + plans + user).
- POST /auth/login : emitAuthSession après verifyCredentials.
- POST /auth/refresh (nouveau) : consumeRefreshToken → emitAuthSession. Rotation : l'ancien token devient révoqué, le nouveau est posé. SPA-side : appelé au boot pour rehydrater + après 401 silencieux.
- POST /account/logout : User.accessTokens.delete + revokeCurrentRefreshToken + clearCookie.

CORS a déjà credentials: true → le cookie traverse cross-origin si origin allowed.

Bruno : nouvelle requête `Auth/04 Refresh.bru` + folder doc + flow décrit dans README. Bruno honore la cookie jar nativement, donc aucun setup additionnel pour tester.

⚠️ Le contrôleur Refresh est nouveau → le registre Tuyau-généré .adonisjs/server/controllers.ts sera régénéré au prochain `pnpm dev:api` (la regen est un effet de bord du boot Adonis, on ne peut pas la déclencher seule). Avant ce premier boot, `pnpm typecheck` échouera sur l'absence de `controllers.Refresh` dans le registre.
2026-05-06 15:05:06 +02:00
ordinarthur
c7714e3e8a feat(api): import OCR (batch + drafts) avec MockOcrProvider
Migrations :
- import_batches (uuid id, organization_id FK CASCADE)
- import_drafts (uuid id, batch_id FK CASCADE, filename, pdf_storage_key nullable, extracted/edited/confidence en jsonb, status ENUM PG natif pending/validated/skipped, invoice_id FK SET NULL)

Schema rules : tape précisément extracted/edited/confidence (sinon `any`) + status enum.

Services :
- OcrProvider : interface (storageKey + filename → champs avec confiance par champ)
- MockOcrProvider : génère des champs plausibles depuis le filename (numero parsed via regex, montants random multiples de 50cts, dates ISO décalées) + 30 % de cas avec emails à confiance basse pour simuler la review UX
- getOcrProvider() : sélectionne via OCR_PROVIDER env var (default mock, mistral en attente d'ADR-020)
- createImportBatchFromFilenames : compose extracted/edited/confidence par draft, tente un match client immédiat (case-insensitive sur le nom) pour pré-remplir clientId
- resolveClient extrait dans un service partagé (3 priorités : clientId → match nom → création + email requis), réutilisé par invoices_controller et import_batches_controller

Endpoints (auth + scope par organization) :
- POST /invoices/upload : V1 mock body { filenames[] }, 201 → ImportBatch avec ses drafts. Multipart upload réel quand Mistral arrivera, contrat de réponse identique.
- GET /invoices/import-batch/:id : poll pendant la review
- POST /invoices/import-batch/:id/drafts/:draftId/validate : crée Invoice (résolution client) + draft.status=validated + draft.invoiceId
- POST .../drafts/:draftId/skip : draft.status=skipped (idempotent)
- DELETE /invoices/import-batch/:id : CASCADE drop drafts, les invoices validées restent

Routes : ordre soigné — /upload, /counts, /import-batch/* AVANT /:id pour éviter le shadowing.

Bruno : nouveau dossier 06-Imports avec 5 requêtes documentées + capture batchId/draftId dans l'env local. README mis à jour avec le parcours étendu (étapes 11-13).
2026-05-06 14:51:37 +02:00
ordinarthur
005af557c2 feat(api): domaine Invoice + endpoints CRUD + branche stats Client/Plan
Migration invoices : uuid id, organization_id FK CASCADE, client_id FK RESTRICT (on n'efface pas les factures si l'utilisateur supprime un client par erreur — audit/comptable), plan_id FK SET NULL, numero, amount_ttc_cents (int, jamais float), issue_date, due_date, status ENUM PG natif (pending/awaiting_user_confirmation/in_relance/paid/litigation/cancelled), pdf_storage_key, notes, rubis_earned, paid_at. Indexes (org,status), (org,client_id), (org,due_date), unique (org,numero).

Modèles : Invoice avec belongsTo Organization/Client/Plan. Client et Plan étendus avec hasMany Invoice maintenant que la table existe.

Endpoints :
- GET /invoices : filtres status/q/clientId/page, tri actionnable (awaiting_user_confirmation puis in_relance puis pending puis litigation puis paid puis cancelled), pagination simple 50/page (cursor-based en V2).
- GET /invoices/counts : compteurs par statut pour les chips dashboard, requête agrégée groupBy.
- GET /invoices/:id : détail enrichi avec client + plan préchargés + timeline composée par buildTimeline() (étapes du plan calées sur due_date, états past/current/future).
- POST /invoices : saisie manuelle. Résolution client en 3 étapes (clientId → match par nom → création à la volée avec email REQUIS, sinon 422 client_email_required). Bonus +1 rubis à la création.
- POST /invoices/:id/mark-paid : status=paid + paid_at + bonus +1 rubis (sur invoice + sur organization.rubis_count). Idempotent.

L'ordre des routes /invoices/counts AVANT /invoices/:id est critique sinon `:id` matche "counts".

Branche les vraies stats :
- ClientStats : agrégation PG une seule requête (count, count actives, count en retard, paid_count, sum paid_cents, sum pending_cents, last_activity) avec FILTER clauses et casting enum::text. Plus de TODO/zéros.
- PlansController : usageCount calculé pareil (factures actives référençant le plan).

Skip pour l'instant (ImportBatch domain à venir) : POST /invoices/upload, GET /invoices/import-batch/*, validate/skip drafts.
2026-05-06 14:33:46 +02:00
ordinarthur
692b514fe9 feat(api): domaine Plan + PlanStep + provisioning des 4 plans pré-fournis
Migrations :
- plans (uuid id, organization_id FK CASCADE, slug nullable, name, description, is_default). Unique (organization_id, slug) — un slug max par org.
- plan_steps (uuid id, plan_id FK CASCADE, order, offset_days, tone ENUM PG natif, subject, body, requires_manual_validation).

Schema rules : override du tone (introspection PG → 'any', on précise l'union).

Modèles Plan (belongsTo Organization, hasMany PlanStep) et PlanStep (belongsTo Plan).

Décision : plans dupliqués par organisation au signup (pas de table globale partagée). Permet l'édition isolée par org sans toucher aux templates des autres tenants. Le service `provisionDefaultPlans(orgId, trx)` est idempotent et appelé depuis NewAccountController dans la transaction de création.

Source de vérité des 4 plans (Standard B2B, Rapide, Patient, Ferme) dans app/services/default_plans.ts — alignée sur apps/web/src/mocks/seed.ts.

Endpoints :
- GET /plans : liste enrichie avec usageCount (à 0 tant qu'Invoice n'est pas câblé).
- GET /plans/:slug : détail (lookup par slug pour URL stable côté SPA).
- PATCH /plans/:slug : édition partielle. Les steps sont remplacés en bloc dans une transaction (pas de diff fin id-par-id, plus simple et prévisible).

POST plan custom = V2 (cf. backend.md §5.5).
2026-05-06 14:25:06 +02:00
ordinarthur
b6006ad1f7 feat(api): domaine Client + CRUD /api/v1/clients
Migration clients (uuid id, organization_id FK uuid CASCADE, name, email REQUIS, phone, address, siret, notes). Index sur organization_id.

Modèle Client avec belongsTo Organization. La relation hasMany Invoice est volontairement omise tant que le domaine Invoice n'est pas câblé.

Validators Vine alignés sur le contrat MSW :
- create : name 2-120, email requis avec format, siret 14 chiffres si fourni
- update : tout optionnel
- email REQUIS au create — pivot produit, pas de relance possible sans

Endpoints (auth requise, scopés par organizationId du user courant) :
- GET /clients?withStats=1&q= : liste filtrée + recherche, enrichissement stats optionnel, tri par actionnabilité (retards d'abord) quand withStats
- GET /clients/:id : détail (id en UUID via router.matchers.uuid())
- POST /clients : 201 + détection doublon par nom case-insensitive → 409 avec payload `existing` (le SPA peut proposer "voir le client existant")
- PATCH /clients/:id : merge partiel

Service ClientStats avec interface bulkComputeClientStats() qui retourne EMPTY pour l'instant — sera vraiment branché quand Invoice arrive. Le contrat reste stable côté SPA, juste les compteurs à 0.

Sérialisation : pour les listes avec stats per-item, on instancie le transformer manuellement (`new ClientTransformer(c).toObject()`) plutôt que de passer par BaseTransformer.transform() qui retourne un Item nested non-unwrappable hors clé directe de serialize().
2026-05-06 14:13:13 +02:00