53 Commits

Author SHA1 Message Date
ordinarthur
b0e6f83655 feat(billing): essai 14 j Pro avec CB à l'inscription (Stripe trial_period_days)
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m19s
Build & Deploy API / build-and-deploy (push) Successful in 1m44s
Build & Deploy Web / build-and-deploy (push) Successful in 41s
Implémente le chantier #6 de docs/tech/landing-optimisations.md. Le
funnel signup propose maintenant un essai 14 j Pro avec carte demandée
mais non prélevée — prélèvement automatique à J+14 avec rappel à J+11
(webhook customer.subscription.trial_will_end de Stripe).

Couverture tests : 60 tests unitaires sur la couche billing
  - billing.spec.ts          (25) — quota Free, bypass trial, inTrial state
  - stripe_billing.spec.ts   (24) — handlers webhook, idempotence, dispatcher
  - trial_recap_job.spec.ts  (11) — stats aggregation, formatRubisToHoursFr
+ 3 nouveaux tests vitest côté SPA (useTrialDaysRemaining,
useIsAtFreeLimit bypass trial).

Backend :
  - Migration 1779000000000_add_trial_ends_at_to_organizations
  - PLAN_CAPS bypass quand status=trialing AND trial_ends_at futur
  - getOrgSubscriptionState expose inTrial + trialEndsAt
  - Refactor handlers webhook en service stripe_billing.ts (pures,
    testables) — extraction depuis le controller. dispatchWebhookEvent
    routeur typé également extrait pour les tests.
  - createTrialCheckoutSession avec subscription_data.trial_period_days=14,
    garde-fou TrialAlreadyConsumedError contre re-trial.
  - handleTrialWillEnd → enqueue job recap (BullMQ jobId déterministe
    basé sur subscriptionId, idempotent contre re-delivery Stripe).
  - Endpoint POST /api/v1/billing/start-trial.
  - Email template trial_recap (React Email, branding Rubis figé) avec
    stats: factures importées, relances envoyées, € récupérés, rubis +
    heures libérées.

Infra de test :
  - tests/helpers/stripe_mock.ts : __setStripeForTests injection +
    factories fakeSubscription / fakeCheckoutSession / fakeInvoice.
  - __setTrialRecapEnqueueForTests : permet de spy l'enqueue sans Redis.

Frontend :
  - /onboarding/billing.tsx (opt-in, pas encore forcé dans le flow) :
    bouton primaire essai 14j + fallback "Free 2 factures".
  - PlanLimitBanner : nouveau état "Essai Pro · X jours restants" qui
    prime sur les autres bandeaux. Discret rubis-glow, non blocant.
  - useStartTrial hook + useTrialDaysRemaining (arrondi sup).
  - SubscriptionState typé avec inTrial + trialEndsAt.

Landing :
  - Sous-texte CTA réactivé : « CB demandée, non prélevée avant J+14 »
    (Hero + FinalCTA), maintenant promesse véridique.

Notes ouvertes (à décider ultérieurement) :
  - Tunnel /onboarding/billing FORCÉ entre signup et /onboarding/compte :
    guard reste à activer (risque cassage du signup actuel sinon).
    Pour l'instant l'écran est accessible mais opt-in.
  - Cron de redondance trial-recap : pas encore implémenté (le
    jobId déterministe BullMQ couvre déjà la double-livraison Stripe).
    À ajouter si on observe des trial sans recap en prod.
  - Tests E2E avec Stripe test mode à faire avant le go-live (cartes
    3DS 4000 0027 6000 3184, declined 4000 0000 0000 0341).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:04:41 +02:00
ordinarthur
f9cba50b5e feat(billing,landing): plan Free 2 factures + scaffold preuves sociales/SEO
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m30s
Build & Deploy API / build-and-deploy (push) Successful in 1m43s
Build & Deploy Web / build-and-deploy (push) Successful in 33s
Suite des chantiers structurants de landing-optimisations.md.

#5 — Plan Free : 5 → 2 factures actives (cf. ADR-023)
  - PLAN_CAPS.free.activeInvoicesLimit dans apps/api/app/services/billing.ts
  - Tests unitaires alignés (4 → 1, 5 → 2 cap, delta 3 → delta 2)
  - billing:scenario command : commentaires + valeur par défaut
  - PlanLimitBanner : copy dynamique via {limit} au lieu de "5" hardcodé
  - /parametres/abonnement : H1 + tile Free (3 mois → 14 jours, 5 → 2)
  - billing.test.tsx (fixtures + cas test)
  - landing copy : hero feature pill, Pricing tile, FinalCTA, CGV §5
  - CLAUDE.md pricing table

#7 — Scaffold <TrustedBy /> (preuve sociale)
  - Composant qui render null tant que copy.trustedBy.{logos,testimonials}
    sont vides — pas de placeholder bidon.
  - Structure data dans copy.ts avec commentaires sur les prérequis
    avant d'ajouter une entrée (accord signé, photo, citation chiffrée).
  - Section insérée juste avant <Pricing /> (cf. doc §4).

#8 — Plan articles SEO + brouillon article 1
  - docs/marketing/seo-articles.md : 5 articles ciblés, mots-clés,
    structure type, lead magnet, calendrier 5 semaines.
  - Article 1 ("Modèle d'email de relance facture impayée") en
    brouillon complet, prêt à valider via l'admin blog (apps/api).

#6 — Plan détaillé migration Stripe trial 14 j (code reporté)
  - docs/tech/stripe-trial-with-card.md : état actuel vs cible,
    architecture (Stripe Checkout + trial_period_days), modifs DB
    (trial_ends_at), API (start-trial + webhook trial_will_end),
    SPA (onboarding/billing), 3 emails transactionnels avec contenu
    intégral, risques + mitigations, plan d'exécution 2,5 j.
  - Implémentation reportée à une session focus avec accès Stripe
    test mode (cartes 3DS, webhook signing secret).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:38:52 +02:00
ordinarthur
7c0767f45e refactor(web): retire i18n EN, app SPA mono-langue FR
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m43s
Build & Deploy API / build-and-deploy (push) Successful in 2m29s
Build & Deploy Web / build-and-deploy (push) Successful in 1m24s
Pendant de cecbddc (landing). Retire le système react-i18next ajouté
en 254f65b — un seul brief utilisateur en français suffit pour l'audience
TPE-PME française visée en V1.

- Désinstalle i18next + react-i18next (≈ 13 kB gzip économisés).
- Supprime apps/web/src/i18n/ (fr.ts, en.ts, index.ts, types.ts) et
  le LanguageSwitcher de /parametres.
- Inline les chaînes FR dans les composants impactés : main.tsx
  (FallbackError), shell (AppSidebar, MobileTabBar, UserMenu), auth
  (login, signup, onboarding/compte), dashboard (_app/index),
  /parametres.
- Met à jour signup : « 30 jours gratuits » → « 14 jours gratuits »
  pour s'aligner sur la landing.

Côté UX visible : plus de switcher langue, plus de détection
navigator.language, plus de localStorage["rubis:locale"].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:05:04 +02:00
ordinarthur
254f65b5d7 feat(web): support i18n EN avec react-i18next, détection auto + switcher
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m38s
Build & Deploy API / build-and-deploy (push) Successful in 2m27s
Build & Deploy Web / build-and-deploy (push) Successful in 1m26s
Stack : `i18next` + `react-i18next` (≈ 42 kB → 13 kB gzip). Init avant
le 1er render dans `main.tsx` pour que les chaînes soient résolues dès
le bootstrap.

Détection de la locale au 1er load :
  1. `localStorage["rubis:locale"]` (préférence explicite de l'user)
  2. `navigator.language` (langue du navigateur)
  3. fallback `fr`

→ un user EN voit l'app en EN dès la 1re visite sans intervention.
La fonction `setLocale()` persiste le choix + synchronise `<html lang>`.

Architecture :
- `src/i18n/{types,fr,en,index}.ts` — même pattern que landing :
  FR fait foi, EN typé par `Dict = typeof fr`.
- `components/settings/LanguageSwitcher.tsx` — radio FR/EN dans
  `/parametres` (section ajoutée en tête).

Surfaces traduites en V1 :
- shell : AppSidebar (nav + compteur rubis), MobileTabBar, UserMenu,
  FallbackError.
- auth : login, signup, onboarding/compte.
- main : dashboard `_app/index`, `_app/parametres` (sections compte,
  entreprise, signature, abonnement, facturation, marque, banque,
  danger zone).

Routes restantes (`factures`, `clients`, `plans`, `insights`,
`admin.blog`, sous-routes parametres) restent en FR inline ; le dico
EN les anticipe déjà via `factures.*`, `clients.*`, `plans.*`,
`insights.*` — il suffira de hooker `useTranslation()` au moment de
traduire ces écrans.

Emails côté API restent FR — à brancher sur une `locale` org plus tard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 13:35:47 +02:00
ordinarthur
7919f20a4f feat(web): enrichit invoice_uploaded + ajoute payment_completed (PostHog)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 27s
- invoice_uploaded : un event par draft (avec invoice_id, filename,
  batch_id, batch_size) au lieu d'un event groupé { count }. Permet de
  lister les uploads individuellement et de cibler une facture précise
  dans les insights. Amount pas dispo à ce stade (OCR pas encore validé).
- payment_completed : nouvel event sur mark-paid, avec amount_cents,
  amount_eur et payment_method (= 'manual' en V1). Alimente le funnel
  facture → paiement et la liste « payeurs » côté PostHog.
- invoice_marked_paid : conservé tel quel pour ne pas casser l'insight
  S5hMZkDE (Invoice paid vs relance launched).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:34:59 +02:00
ordinarthur
3fc3a7456a feat(web): instrumentation PostHog (analytics + nginx proxy)
Some checks failed
Build & Deploy Web / build-and-deploy (push) Has been cancelled
Build & Deploy API / build-and-deploy (push) Successful in 2m30s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m31s
Setup PostHog côté SPA — pageviews TanStack Router + 10 events business
(signup, login SSO, upload facture, émission/brouillon facture native,
marquer payée, lancer relance, plan créé, checkout Stripe). PostHogProvider
dans __root.tsx, identify sur auth, proxy nginx /ingest/* → eu.i.posthog.com
pour contourner les adblockers. Token bake via build-arg CI
(POSTHOG_PROJECT_TOKEN, à ajouter côté Gitea Secrets).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:21:59 +02:00
ordinarthur
b81bc2609b fix(parametres): teaser banking l'emporte sur upsell + section démo admin-only
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 42s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m26s
Build & Deploy API / build-and-deploy (push) Successful in 2m10s
Deux bugs visibles dans /parametres :

1. **Banque** — un user Free voyait la carte "Plan Pro ou Business
   requis" alors que la feature est gated derrière BANKING_ENABLED=false
   en prod (Powens KYC en cours). L'upgrade n'aurait rien débloqué.
   Fix : la branche `comingSoon` court-circuite l'upsell, et le titre +
   description de la SettingsSection bascule en mode teaser ("Bientôt :
   votre banque connectée à Rubis") pour rester cohérent avec la carte
   "Bientôt disponible" en dessous.

2. **Démonstration** — la section apparaissait pour tous les users,
   alors que c'est un outil de prospection commerciale réservé aux
   admins Rubis (horloge virtuelle + capture des emails). Déroutant
   pour un user lambda.
   Fix : section gated sur `user.isAdmin` côté UI, et split des routes
   /demo côté API :
   - GET /demo/state reste accessible à tous les users authed (sinon le
     DemoClock dans AppLayout spam des 403 sur chaque page). Un user
     normal reçoit `{active: false}` — pas de leak.
   - GET /demo/inbox + POST /demo/start, /end, /tick : auth + admin.
     Mutations et lecture des emails capturés.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:57:37 +02:00
ordinarthur
aa6468e9a0 feat(web): éditeur de factures /factures/nouvelle (Phase 4)
Page split-view qui permet de composer une facture native dans Rubis
avec preview PDF en live (debounce 500 ms via POST /invoices/preview-pdf
→ Blob → objectURL → iframe).

UI
- Gauche : panneau d'édition organisé en cards (destinataire,
  dates + plan, lignes éditables, thème + accent, notes).
- Droite : iframe sticky qui affiche le PDF rendu côté serveur. Loader
  discret pendant la génération, fallback "sélectionnez un client" tant
  qu'on n'a pas un payload minimal valide.
- Lignes : ajout/suppression, quantité décimale (heures, demi-jours),
  taux TVA selon FRENCH_TVA_RATES, total HT recalculé live.
- Totaux client-side : mêmes règles d'arrondi (Math.round par ligne)
  que invoice_totals.ts côté serveur — feedback instantané, le serveur
  recalcule à la persistance.
- Footer sticky : "Enregistrer en brouillon" / "Émettre la facture",
  avec rappel que l'émission consomme la séquence (irréversible).

API client
- `useCreateNativeInvoice` : POST /invoices/native, invalide les caches
  invoices + counts.
- `previewInvoicePdf(input, signal)` : POST /invoices/preview-pdf qui
  retourne un Blob (annulable via AbortSignal pour les frappes rapides).
- `api.postBlob` : helper générique POST+JSON → Blob (inverse de fetchBlob).

Defaults : les settings résolus de l'org (theme, accent, paymentTermsDays)
sont chargés une fois au mount et appliqués comme valeurs initiales.

Liste factures : remplace le bouton "Nouvelle facture" par deux actions
côte-à-côte — "Importer" (secondaire, mène à /factures/import) et
"Créer une facture" (primaire, mène à /factures/nouvelle).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:07:41 +02:00
ordinarthur
0680bb9f77 feat(web): page /parametres/facturation — paramétrage de la facturation (Phase 3)
Ajoute la page de configuration de l'éditeur de factures natif côté SPA,
plus les hooks React Query pour /organizations/me/invoice-settings et
/invoice-themes.

Sections (chacune avec son propre Save → blast radius clair) :
1. Identité émetteur (raison sociale, forme juridique, adresse structurée,
   SIREN/SIRET/TVA intracom, NAF, RCS, capital, contact). Snapshotée
   à chaque émission dans `invoices.issuer_snapshot` — modifier ces
   champs n'altère pas les factures déjà émises.
2. RIB (IBAN normalisé à l'enregistrement, BIC, nom de banque).
3. Numérotation avec aperçu live "FAC-2026-0042" — préfixe + prochain
   numéro + padding éditables. Une fois la première facture émise, le
   compteur s'auto-incrémente.
4. Mentions & délais — délai de paiement par défaut + textes légaux
   préremplis (pénalités art. L441-10, escompte art. L441-9, libre).
5. Thème par défaut + couleur d'accent — galerie 4 thèmes avec previews
   miniatures CSS (pas de PDF embed pour la galerie : trop lourd).

Ajoute aussi le lien vers /parametres/facturation depuis /parametres
(section "Facturation" placée avant "Marque").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:32:07 +02:00
ordinarthur
3207f873e9 feat(banking): mode "Bientôt disponible" pendant la fenêtre KYC Powens
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m29s
Ajoute un état intermédiaire entre "feature désactivée silencieusement"
et "feature pleinement active" : un teaser visible dans /parametres
pour les Pro/Business qui annonce que la connexion bancaire arrive,
avec une note rassurante sur la lecture seule. Permet d'annoncer la
feature aux users payants pendant le délai d'agrément AISP / KYC
Powens, sans risque de cliquer dans le vide.

- Nouveau flag d'env `BANKING_TEASER_ENABLED` (boolean, default false)
- `GET /banking/status` renvoie désormais `{ enabled, comingSoon }`
  où comingSoon = !enabled && BANKING_TEASER_ENABLED
- `BankingSection` : nouveau composant `ComingSoonCard` (halo glow,
  copy explicite sur l'agrément AISP en cours, rassurance lecture
  seule) affiché quand comingSoon=true et l'org est Pro/Business
- `parametres.tsx` : la section "Banque" apparaît si enabled OU
  comingSoon (au lieu de uniquement enabled)
- ConfigMap K3s : `BANKING_TEASER_ENABLED='true'` en prod pour
  annoncer la feature aux clients payants pendant le KYC

Trois états possibles désormais :
  enabled=true                       → feature active (post-KYC)
  enabled=false + comingSoon=true    → teaser "Bientôt disponible"
  enabled=false + comingSoon=false   → section invisible (kill switch dur)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:14:33 +02:00
ordinarthur
51217175ad feat(banking): intégration Powens AISP + auto-réconciliation factures
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).

Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
  webview URL, list/get/delete connections, accounts, transactions,
  vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
  encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
  (TTL 10 min), handleCallback (upsert connection + accounts +
  fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
  Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
  + label normalisé (numero ou nom client, NFD strip + alphanum).
  Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
  HIGH → invoice.status=paid + bonus rubis + cancel relances +
  enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
  (user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
  powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
  USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
  / SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
  puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
  + colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
  sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
  récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
  (notif user après match auto, lien direct facture + libellé
  bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
  jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
  reconnecter la banque) et banking:simulate-payment (injecte une
  bank_transaction synthétique qui matche une facture, pour test E2E
  sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
  credentials Powens. Endpoint public GET /banking/status renvoie
  { enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
  rapporte désormais la vraie colonne en faute (numero/slug/…) avec
  message lisible « Le numéro de facture "F2026-0013" existe déjà »,
  au lieu d'un message ambigu sur organization_id.

Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
  switch + plan Pro/Business. Si Free → upsell card avec CTA vers
  /parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
  une banque". Si banque connectée → carte avec accounts (IBAN
  masqué FR76 **** **** **** 1234), solde, last sync, bouton
  Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
  avec badge ✓ animé + halo glow rubis, récap des comptes
  synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
  note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
  { enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
  useUpdateBankingSettings.

Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
  BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
  POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
  POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
  POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
  via kubectl patch, à remplacer post-KYC).

Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
  clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
  (anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
  POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
  powens_account_id), (account, powens_id) → rejouer un event ou un
  callback ne pose pas de problème.

Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
  Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
  prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
  flip flag → smoke test), snippet kubectl patch pour rotation
  ciblée de secrets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:03:32 +02:00
ordinarthur
363caf8061 feat(web): UI marque blanche — page /parametres/marque + intégration
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 39s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m6s
Complète la feature marque blanche initiée dans le commit précédent
(919ebfe). L'API backend est désormais consommée par une page
dédiée dans le SPA, et le changelog v1.11.0 décrit la feature complète
plutôt que la "première brique".

Livraison côté SPA :

- `apps/web/src/lib/brand.ts` — types BrandSettings/BrandTokens (miroir
  serveur) + 5 hooks TanStack Query : useBrand (GET cache), useUpdateBrand
  (PATCH), useUploadBrandLogo (multipart), useDeleteBrandLogo, et
  useSendBrandTestEmail. Pas de retry sur le GET pour éviter de bombarder
  /brand quand l'org n'est pas Business (403 définitif).

- `apps/web/src/components/settings/BrandEmailPreview.tsx` — mock fidèle
  d'un email de relance qui réagit en direct aux color pickers. Copie la
  structure HTML/CSS de relance_email.tsx + _layout.tsx (banner, body
  pre-line, card récap, footer Rubis) pour que le user soit confiant que
  son vrai email rendra pareil.

- `apps/web/src/routes/_app/parametres_.marque.tsx` — page éditeur
  complète :
  • Header avec retour
  • Upsell card propre si l'org n'est pas Business (pas d'éditeur du tout
    pour éviter de leak des controls qui throw 403 derrière)
  • Form 2 colonnes desktop : zone upload logo (drop ou click) avec
    preview sur le bandeau effectif, input nom expéditeur, color pickers
    natifs (HTML5 + hex input) groupés en "Principales" (primary + banner)
    et "Avancées" (7 autres, accordéon fermé par défaut)
  • Live preview à droite (sticky desktop) qui se met à jour à chaque
    keystroke / pick
  • Actions : Enregistrer (diff draft → settings → PATCH), Réinitialiser
    (tous les overrides à null), Envoyer un test (qui force l'enregistrement
    préalable parce que le test utilise les tokens sauvegardés)
  • Sémantique null/undefined respectée côté patch — undefined = pas
    touché, null = reset au default Rubis sur ce champ précis

- Carte de navigation ajoutée dans `/parametres` qui linke vers
  `/parametres/marque` avec libellé adaptatif (Business = "Configurer",
  autres = "En savoir plus").

Changelog v1.11.0 réécrit pour décrire la feature complète et non plus
seulement la moitié backend. Un seul concept, une seule entrée changelog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:05:36 +02:00
ordinarthur
847e7a3fc5 feat(web): toast "Nouvelle version" persistant + lien vers le changelog
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 35s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m6s
Composant `<VersionToast/>` monté au root de la SPA qui s'affiche quand
l'utilisateur ouvre l'app sur une version plus récente que la dernière
qu'il a vue.

Mécanique :
- Source de vérité = `apps/web/src/version.ts` (constante semver, à bumper
  manuellement à chaque release, en même temps que l'ajout du .md
  correspondant dans `apps/landing/src/content/changelog/`).
- Comparaison à `localStorage["rubis:last-seen-version"]` au mount.
- 1re visite (clé absente) → on enregistre la version courante en silence,
  pas de toast (sinon spam d'onboarding).
- Version identique à la dernière vue → rien.
- Version différente → toast Sonner persistant (`duration: Infinity`)
  avec icône Sparkles rubis et action "Voir les nouveautés ↗" qui ouvre
  `rubis.pro/changelog#<version>` dans un nouvel onglet. Au moment de
  l'affichage on enregistre la version comme vue — donc même si l'user
  ferme sans cliquer, plus de toast pour cette version (il a été informé).
- localStorage indisponible (private mode) → fail silent.

Version initiale : `1.10.0` (remerciement automatique au client, dernière
entrée du changelog).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 00:06:06 +02:00
ordinarthur
52bc7507fb fix(blog/admin): expose contentMd dans PostTransformer + nullish guards
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 35s
Build & Deploy API / build-and-deploy (push) Successful in 1m27s
Le PostTransformer ne renvoyait que contentHtml — l'éditeur admin avait
besoin du contentMd source pour permettre l'édition, et plantait avec
"Cannot read properties of undefined (reading 'replace')" dans countWords()
au mount.

* PostTransformer expose maintenant contentMd, status et createdAt en
  plus de l'existant. Surcoût ~quelques KB par requête côté landing
  publique (négligeable). Si volume devient un problème, on splittera
  en PublicPostTransformer + AdminPostTransformer.
* admin.blog_.$id.tsx : nullish coalescing sur tous les champs string
  au moment d'init le draft (defense in depth — si l'API renvoie
  jamais un payload partiel, l'éditeur reste fonctionnel).
* countWords() accepte maintenant string | null | undefined.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:40:53 +02:00
ordinarthur
6dcae6956c feat(blog): admin CRUD + image upload + sidebar link
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m32s
Build & Deploy API / build-and-deploy (push) Successful in 2m20s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m20s
L'éditeur du blog (jusqu'ici limité au seeder) a maintenant une vraie
interface au-dessus de l'API.

Backend (apps/api) :
* Migration users.is_admin (boolean default false).
* Middleware admin (404 si user.is_admin=false après auth).
* Commande ace promote:admin --email=… [--revoke].
* AdminPostsController CRUD complet : list/show/store/update/publish/
  unpublish/destroy + suggest-slug. Au save, contentHtml + wordCount +
  readingTime sont re-calculés via blog_renderer. Au publish, durcit la
  validation SEO (titre ≤60, excerpt 120-160, hero+alt requis, ≥600 mots),
  flippe status='published' + publishedAt, ping Google+Bing pour le sitemap.
* BlogUploadsController :
  - POST /api/v1/admin/uploads (multipart, JPEG/PNG/WebP, max 4MB)
    → MinIO clé uploads/blog/{uuid}.{ext}
    → renvoie URL relative /api/v1/uploads/blog/{filename}
  - GET /api/v1/uploads/blog/:filename (public, cache immutable 1 an)
    → stream depuis MinIO, regex anti-traversal sur le nom.
* UserTransformer expose isAdmin (cf. shared/types/user).
* k3s/app/landing.yml : NodePort 30111 explicite (pour Traefik repo proxmox).

Frontend (apps/web) :
* Lib typée admin-blog (calls API, queryKeys, helpers URL).
* Route /admin/blog : liste filtrable avec status badge, ouverture
  publique, dépublier, supprimer, "+ Nouveau brouillon".
* Route /admin/blog/:id : éditeur 2-colonnes
  - Gauche : @uiw/react-md-editor (lazy import) avec preview live.
  - Droite : hero image (drag&drop + alt), excerpt avec compteur
    120-160, tags, aperçu Google snippet, validations bloquantes.
  - Autosave debounce 2s + bouton Publier qui sauve d'abord.
  - Hero image upload via MinIO (HeroImageUpload component).
* Sidebar : lien "Blog (admin)" si user.isAdmin.
* Gate côté client (beforeLoad redirect si non admin) + côté serveur
  (middleware admin) — defense in depth.

Note : les requirements de publish miroir backend ↔ frontend (cf.
PUBLISH_REQUIREMENTS dans validators/post.ts et VALIDATION_RULES dans
admin.blog_.\$id.tsx). À synchroniser si un seuil bouge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:25:34 +02:00
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
06dcf38fee feat(ui): DatePicker brandé Rubis (remplace input type=date)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 20s
Le natif <input type="date"> affiche le calendrier moche du navigateur
(différent par client OS, pas d'alignement palette). On le remplace par
un composant Radix Popover + grille mensuelle Tailwind aux couleurs
Rubis.

Composant : apps/web/src/components/ui/DatePicker.tsx
- Trigger : bouton style Input (border line, focus rubis-glow,
  data-[state=open]:border-rubis pour hint visuel)
- Popover Radix : focus trap, Escape, click outside, animation
- Grille 7×6 (semaine commence lundi, locale FR via date-fns)
- Sélection : bg-rubis text-white + ombre rubis
- Today : ring-inset rubis-glow
- Hover : bg-cream
- Raccourci "Aujourd'hui" en footer

API alignée avec l'usage existant :
- value: string ISO | Date | null
- onChange(date: Date) — l'appelant fait .toISOString() comme avant

Usages migrés :
- ManualInvoiceDialog.tsx : Date d'émission
- factures_.import_.$batchId.tsx : Date d'échéance (avec préservation
  du className aria-invalid pour les low-confidence OCR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:01:31 +02:00
ordinarthur
0f1a309be3 fix 2026-05-07 17:20:33 +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
fd24ef42a6 feat(billing): redesign page abonnement — layout asymétrique + identité Rubis
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s
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>
2026-05-07 16:34:46 +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
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
ordinarthur
92a9fac62b feat(checkin): modale in-app pour confirmer le paiement au login
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>
2026-05-07 12:37:09 +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
1633fb9bf0 add factories
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 59s
Build & Deploy API / build-and-deploy (push) Successful in 1m37s
2026-05-07 11:34:00 +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
6eb9ca4120 feat(ui): GlossaryTerm — tooltip de définition sur DSO / LME / Mise en demeure
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>
2026-05-07 10:15:44 +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
4113cb56d3 feat(import): preview réelle du PDF/image dans le volet gauche
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>
2026-05-07 09:49:08 +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
ea539cd1d4 feat(auth): Google SSO via @adonisjs/ally
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 55s
Build & Deploy API / build-and-deploy (push) Successful in 1m35s
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>
2026-05-07 09:24:27 +02:00
ordinarthur
461ab9bcd9 feat(deploy): app.rubis.arthurbarre.fr — image, manifests K3s, route Traefik
Some checks failed
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy App / build-and-deploy (push) Failing after 46s
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>
2026-05-07 02:01:39 +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
0a3b8523ef feat(plans/wizard): éditeur avec icônes de tonalité + toggle de sélection
- 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>
2026-05-06 23:34:33 +02:00
ordinarthur
07712da774 fix(plans/wizard): calendrier vraiment pratique (3 problèmes UX)
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>
2026-05-06 23:29:39 +02:00
ordinarthur
a136c54501 feat(plans/wizard): cadence sur calendrier mensuel avec tonalités
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>
2026-05-06 23:18:27 +02:00
ordinarthur
05ad3fa5cf refactor(plans/wizard): refonte cadence en liste verticale lisible (mobile + desktop)
Le précédent layout avec ◆ rotatés en timeline causait des collisions
visuelles sur mobile (les coins du diamant débordaient sur les labels et
la ligne de connexion). Inutilisable.

Nouvelle approche, inspirée des éditeurs de séquences éprouvés
(Mailchimp, Klaviyo) : liste verticale de cards de step, identique
sur mobile et desktop. Plus prévisible, plus lisible, mêmes tap targets.

- Chaque step = card cliquable avec : numéro d'ordre, ◆ accent (petit,
  coloré par tonalité, signature de marque sans gêner la lecture),
  J+X, label de tonalité, bouton retirer aligné dans le flux
- La card sélectionnée (rubis border + shadow) révèle l'éditeur inline
  (Décalage + Tonalité) directement sous l'en-tête → pas de panneau
  séparé, pas de saut de focus, l'utilisateur édite ce qu'il vient
  de taper
- Bouton "Ajouter une étape" en pleine largeur en pied de liste
- L'avertissement mise-en-demeure (validation manuelle) s'affiche dans
  la card sélectionnée
- OffsetInput déplacé dans CadenceTimeline avec le reste de l'éditeur ;
  duplication supprimée du fichier route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:10:32 +02:00
ordinarthur
24cbf35902 fix(plans/wizard): variables dans le sujet + UX mobile resserrée
Variables
- Le clic sur un chip de variable insère désormais au curseur du dernier
  champ focus (sujet OU corps), pas seulement dans le corps. On capture
  la position via onSelect/onClick/onKeyUp/onBlur et on utilise mousedown
  + preventDefault sur les chips pour que le focus ne quitte pas le champ
  ciblé avant l'insertion. Le label sous les chips indique en live
  quel champ est ciblé.
- OffsetInput (étape Cadence) : composant string-controlled qui accepte
  les états intermédiaires "" et "-" pour ne plus avoir le 0 fantôme
  quand on efface pour ressaisir un offset négatif.

Mobile
- Bottom nav (Annuler/Continuer) sticky en bas sur mobile, en flux normal
  sur desktop. Safe-area inset respectée.
- Header du wizard : back button compact (icône seule sous sm), compteur
  d'étape toujours visible, stepper centré.
- Card padding adaptatif (p-5 sm:p-7 lg:p-9).
- Step 3 — sélecteur d'étape : scroll horizontal sur mobile (au lieu de
  wrap), évite l'effet escalier avec 5 étapes.
- Step 3 — body textarea : min-h adaptatif (180px mobile, 260px sm+).
- CadenceTimeline : rail horizontal masqué sous lg ; en mobile, ligne
  verticale fine entre les nœuds (cohérent identité ◆) ; bouton retirer
  visible en permanence sur mobile (les hover-only ne marchent pas tactile).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:05:34 +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
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
8cec9d2f33 feat(web): page /parametres complète (compte, entreprise, signature, danger)
Remplace le placeholder par 4 sections fonctionnelles, chacune avec son
form indépendant et son Save (blast radius clair : modifier sa signature
ne sauvegarde pas l'org).

Layout : sections verticales avec gap large, pas de tabs ni sidebar
interne en V1 (mono-utilisateur, peu de surface). Pattern type Linear /
Stripe : eyebrow + titre + description à gauche (280px), Card form à
droite (1fr). Empilé sur mobile.

Sections :

1. Compte — AccountForm : fullName + email. Synchronise authStore
   après save → topbar greeting / sidebar avatar se mettent à jour
   live. Save désactivé si form.state.isDirty=false.

2. Entreprise — OrganizationForm : nom + SIRET (14 chiffres) + chips
   volume mensuel (réutilise le pattern de l'onboarding step 2).
   Fetch GET /organizations/me, PATCH au save, setQueryData pour
   éviter un refetch.

3. Signature — SignatureForm : Textarea + aperçu live dans Card flat
   avec eyebrow + Sparkles (cohérent onboarding step 3). PATCH
   /account/profile avec field signature.

4. Zone danger — DangerZone, variant 'danger' sur SettingsSection
   (border rubis-deep/30 dashed + bg rubis-glow/20 — sobre, pas
   alarmiste). Logout fonctionnel (duplique UserMenu, c'est OK et
   attendu dans les paramètres). Suppression compte disabled
   (bientôt) avec mention 'RGPD article 17'.

Composants nouveaux :
- SettingsSection : pattern visuel commun, prop tone='default'|'danger'
- AccountForm, OrganizationForm, SignatureForm, DangerZone

MSW : ajout GET /api/v1/organizations/me (on n'avait que le PATCH).

Bundle prod : 116.21 KB gzip core (-1.76 KB grâce au tree-shaking
mutualisé des deps form).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:29:31 +02:00
ordinarthur
16120ed3e0 feat(web): création client (modale) + email required + SIRET optionnel
Réflexion produit : email required vs optionnel.

Le coeur de Rubis = relances email automatiques. Sans email client →
aucune relance ne peut partir → la fiche client est inutilisable pour
le coeur du produit. Décision : email REQUIRED partout, plutôt que
laisser créer des fiches mortes.

Type Client (packages/shared) :
- email: string (était string | null)
- siret: string | null ajouté (optionnel mais recommandé pour mises
  en demeure formelles + intégrations comptables V2 type Pennylane)

ClientCreateDialog (modale "+ Nouveau client" sur /clients) :
- Email required avec validator Zod min(1).email()
- SIRET ajouté côte-à-côte avec Téléphone (validator 14 chiffres
  ou vide, inputMode='numeric', espaces tolérés à la frappe)
- Adresse postale déplacée full-width (lisibilité)
- Hints éducatifs : 'Préférez compta@/facturation@ à une nominative',
  'Recommandé pour les mises en demeure', 'Requise pour les mises en
  demeure formelles'

Field component aligned :
- Label/hint en haut, input en bas (mt-auto sur le wrapper input)
- Quand 2 Fields sont côte-à-côte avec hints de longueur différente,
  les inputs restent alignés au bas — le hint plus long étire le haut
- Erreur reste collée sous l'input (pas en bas de la cellule)

MSW :
- POST /clients schema strict : email required, siret 14 chiffres si fourni
- Détection doublon par nom (409) conservée
- Handlers création de client implicites (saisie facture, OCR review)
  refusent maintenant la création quand email manquant : 422 ciblé
  'Email du client requis — Rubis en a besoin pour envoyer les relances.'
  Si l'user pick un client existant via le combobox → email déjà en
  DB, pas demandé.

Migration mockDb :
- Anciens clients sans siret → null
- Anciens clients avec email null (cas test) → placeholder dérivé du
  slug du nom (contact@boulangerie-martin.fr) — éditable, juste évite
  un crash au load. slugifyClientName() supprime SARL/SAS/EURL et accents.

Détail /clients/$id :
- SIRET ajouté dans la barre meta du header (Hash icon Lucide +
  tabular-nums) — affiché seulement si rempli
- Email plus conditionnel (toujours présent maintenant)

Seeds :
- Boulangerie Martin SARL : SIRET 82345678900012
- Cabinet Rousseau : SIRET 53412987600028
- Atelier Durand, Garage Lemoine, Studio Lefèvre : siret null
  (pour tester les deux cas dans la liste)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:25:37 +02:00
ordinarthur
f34cc97327 feat(web): /clients liste + détail + persistance session mock
Page /clients (liste) :
- Header dynamique : 'X factures en retard chez Y clients' en rubis-deep
  s'il y en a, sinon 'Tout est calme côté clients.'
- Recherche par nom/email (param q côté serveur, debounce naturel via
  TanStack Query staleTime: 10s)
- Table desktop / cards mobile (cohérent avec /factures)
- Tri serveur : retards d'abord (actionable), puis activité récente
- Empty state distincts (recherche vide vs jamais de clients)
- Lien depuis 'Voir tout' du panel TopLatePayers du dashboard fonctionne
- '+ Nouveau client' disabled (V2)

Page /clients/$id (détail) :
- Header : eyebrow contextuel, nom h1, infos contact (mail clickable,
  phone, address) avec icônes Lucide ink-3
- 4 KPI cards en grille : Factures actives (avec sub-info 'N en retard'
  rubis-deep si pertinent), En attente, Encaissé total, Factures payées
- Liste des factures du client (cliquables vers /factures/$id) avec
  StatusBadge sans icône (compact)
- Notes internes : Textarea avec autosave on blur via PATCH /clients/:id

MSW :
- GET /clients?withStats=1&q= : enrichit avec compteurs + montants +
  lastActivityAt. Tri par retards d'abord
- GET /clients/:id : détail enrichi + invoices triées plus récentes
- PATCH /clients/:id : édition Zod
- mockDb.updateClient(orgId, id, patch) ajouté

Persistance session mock (stay logged in après reload) :
- mocks/sessionStore.ts : helpers localStorage simulant le cookie
  httpOnly côté serveur. TTL 30j (= refresh token typique). SPA n'y
  accède jamais directement, seul MSW touche cette persistance.
- POST /auth/{login,signup} : sessionStore.set après succès
- POST /auth/logout : sessionStore.clear (clean disconnect)
- POST /auth/refresh : retourne la session stockée + recharge le user
  depuis mockDb au cas où il a été modifié (signature post-onboarding etc.)
- main.tsx : bootstrapSession() avant le 1er render (silent refresh).
  Évite le flash redirect /login pour les users déjà connectés.

Architecture : le SPA n'accède jamais directement à localStorage —
il passe toujours par HTTP (/auth/refresh). Quand on branchera le vrai
backend Adonis, on supprime juste mocks/sessionStore.ts et le pattern
continue à marcher (cookie httpOnly remplace localStorage côté serveur).

queryKeys.clients.list ajouté pour le param de recherche.

Bundle prod : 117.92 KB gzip core (stable +0.28 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:06:32 +02:00
ordinarthur
6de2711aa8 feat(web): OCR review utilise ClientCombobox au lieu d'un Input libre
L'écran de review OCR avait un Input texte libre pour le nom du client,
ce qui faisait qu'on créait un nouveau client à chaque validation même
quand le nom matchait un client existant — doublons assurés.

Maintenant l'OCR fait le matching en amont :
- L'extraction côté MSW (fakeOcrExtract) cherche un client existant par
  nom case-insensitive et pré-remplit clientId dans extracted/edited.
  Confidence clientName = 1 quand match (vs 0.95 sinon).
- DraftFields type ajoute clientId: string | null
- draftFieldsSchema (validation) ajoute clientId nullable

Côté UI :
- L'Input clientName devient un ClientCombobox (le même que pour la
  saisie manuelle — chunk mutualisé 26 KB gzip)
- Border rubis quand un client existant est sélectionné
- Hint contextuel sur le Field :
  · clientId set → "Lié à un client existant ✓"
  · clientId null + nom ≥ 2 chars → "Nouveau client — sera créé à la validation."
  · Sinon → "Tapez pour rechercher ou créer un client."

Validate handler MSW (résolution client en cascade) :
1. clientId explicite (combobox) → utilise direct, zéro lookup
2. Match par nom case-insensitive sur les clients existants → utilise si match
3. Création à la volée si rien ne matche
Fallback création si clientId fourni mais introuvable.

Migration mockDb : les batches d'import seedés avant l'ajout du champ
sont patchés à load() avec clientId ?? null (spread des données stockées
d'abord pour ne pas écraser les snapshots récents).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:55:12 +02:00
ordinarthur
cfd3680bb4 feat(web): saisie manuelle de facture (modale Radix Dialog)
Modale 'Nouvelle facture' (cf. wireframe 2.3) accessible depuis 4 points :
- Topbar '+ Saisir' (était disabled)
- /factures/import bouton 'Saisir manuellement' (header)
- Dropzone empty state sur /factures (variant full)
- (Reachable de partout dans _app/* via le topbar)

Composants ajoutés :
- Dialog : wrapper Radix Dialog stylé (overlay ink/35 + blur, content
  bg-cream + border-line + shadow-card, close button discret, animations
  fade+zoom). Header / Title / Description / Footer / Close.
- ClientCombobox : autocomplete maison (pas Radix Combobox qui n'existe
  pas, pas cmdk overkill). Input + dropdown filtré, click-outside ferme,
  Escape ferme, option 'Créer le client « X »' quand pas de match exact.
  Border rubis quand un client existant est sélectionné.
- ManualInvoiceDialog : form complet (TanStack Form + validateurs Zod
  par champ). Client (combobox), N° + date émission (côte-à-côte), montant
  + échéance relative 15/30/45/60/90j (Select Radix), plan de relance.

Architecture clean :
- ManualInvoiceProvider au sommet d'AppLayout rend la modale une seule
  fois (un seul réseau de portals Radix)
- Hook useManualInvoice() expose open()/close()/isOpen, accessible
  depuis n'importe quelle route enfant sans plumber des callbacks
- État local de la modale (pas dans l'URL — propre pour V1)

Logique métier MSW :
- GET /api/v1/clients (autocomplete)
- POST /api/v1/invoices : résolution client (clientId fourni → utilise,
  sinon match par nom case-insensitive, sinon création à la volée).
  +1 rubis bonus saisie.
- Conversion relativeDueDays (15/30/45/60/90) → dueDate absolue à la
  soumission

Bug fix montant TTC :
- L'input était contrôlé avec value={(cents/100).toFixed(2)} → reformat
  à chaque keystroke écrasait '10000' en '1.00' (impossible de taper
  des gros montants)
- Passé en defaultValue (uncontrolled) avec step='any' + inputMode='decimal'
- Accepte virgule FR (1240,50) et point (1240.50)
- DialogContent unmount à la fermeture → defaultValue ré-évalué à
  chaque réouverture (reset OK)

Bouton '+ Saisir' du topbar plus disabled, bouton 'Saisir manuellement'
de /factures/import plus disabled. Le bouton dans la dropzone (variant
full) reçoit un onManualEntry prop optionnel.

Bundle prod : 117.62 KB gzip core (+0.06 KB), useManualInvoiceDialog
chunk 6.68 KB gzip, Select chunk 25.14 KB gzip (partagé OCR + plan
editor + manual entry).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:50:46 +02:00
ordinarthur
965a92da8f feat(web): /factures/import — page focused d'import via bouton topbar
Le bouton '+ Importer factures' du topbar avait un Button inerte. Il
ouvre maintenant une vraie page focused dédiée :

- Route /factures/import (factures_.import.tsx) avec breadcrumb,
  eyebrow, H1 'Importer *plusieurs* factures.', lede explicatif,
  dropzone full-page avec mutation upload câblée
- Drop-catcher de page comme sur /factures (drop n'importe où marche)
- 3 hints discrets en bas (Formats / Confidentiel / Reprenable) pour
  rassurer le user au moment décisif de l'upload

Routing nesting fix :
- Renommé factures_.import.\$batchId.tsx → factures_.import_.\$batchId.tsx
- Trailing underscore sur 'import_' escape la nouvelle landing parent
- Les 2 routes sont maintenant siblings sous _app :
  · /factures/import → factures_.import.tsx
  · /factures/import/\$batchId → factures_.import_.\$batchId.tsx

Topbar AppLayout :
- '+ Importer factures' = Button asChild + Link to /factures/import
  (middle-click / cmd-click / right-click ouvrent un nouvel onglet)
- '+ Saisir' reste disabled (placeholder modale 2.3, prochaine étape)

Bundle prod : 117.56 KB gzip core (stable, +0.06 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:35:59 +02:00
ordinarthur
86dae64eb4 feat(web): import OCR — drop fichier → review batch → factures créées
Boucle import complète (cf. wireframe 2.2) :
1. Drop PDF/PNG/JPG sur /factures (dropzone full-page si vide, compact en
   bas si populée, OU drop n'importe où sur la page grâce au drop-catcher
   de route — évite que le browser ouvre le fichier dans un onglet)
2. POST /invoices/upload → MSW génère un batch avec drafts pré-remplis
   (OCR simulé : nom client aléatoire depuis 7 entreprises plausibles,
   montant random, dates calibrées, confidences variables) + délai 800ms
3. Toast "X factures extraites. Vérifions ensemble." + navigate vers
   /factures/import/$batchId
4. Page review step-by-step : PDF preview à gauche + form à droite,
   champs douteux (confidence < 0.7) surlignés border-rubis + hint
   inline, bandeau warning rubis-glow si plusieurs champs incertains
5. Valider & suivante → POST validate → crée la facture en mockDb
   (nouveau client si nom inconnu) + 1 rubis bonus → la suivante
   apparaît automatiquement
6. Skip ou Annuler le batch entier disponibles à tout moment
7. Fin de batch → toast bilan ("X validées · Y ignorées") → /factures

Composants ajoutés :
- PdfPreview : placeholder anti-générique (pas un viewer gris) — header
  mono filename + "page A4" simulée avec barres bg-ink/15 et bg-rubis-glow
- Select : wrapper Radix Select stylé (Trigger / Content / Item) cohérent
  avec Input (1px line, focus rubis-glow ring, item sélectionné rubis + ✓)

Dropzone amélioré :
- Filtre fichier plus tolérant : MIME OU extension (Finder/Explorer
  envoient parfois type === ""), erreur dédiée taille vs format
- Mode isUploading : titre devient "On lit vos factures…", spinner
  sur le bouton Parcourir

MSW handlers (invoices.ts) :
- POST /invoices/upload : crée batch + drafts avec OCR simulé
- GET /invoices/import-batch/:id
- POST /invoices/import-batch/:id/drafts/:draftId/validate
- POST /invoices/import-batch/:id/drafts/:draftId/skip
- DELETE /invoices/import-batch/:id

mockDb étendu :
- importBatches store + StoredImportDraft type
- createImportBatch / findImportBatch / updateImportDraft / deleteImportBatch
- createInvoice / createClient / listClientsForOrg

Bug fix migration :
- Le sessionStorage stockait des snapshots d'avant l'ajout du champ
  importBatches → db.importBatches undefined → push() crashait. Ajout
  d'une migration douce dans load() qui patche les champs manquants
  avec les défauts du seed (pas de perte de données existantes).

Bundle : 117.50 KB gzip core. Route factures_.import._batchId 10.26 KB
gzip — la plus grosse à cause de Radix Select + state form complexe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:26:31 +02:00
ordinarthur
b5b67056aa feat(web): plans bibliothèque + éditeur
Bibliothèque /plans (cf. wireframe 3.1) :
- Grid responsive 1/2/3 cols avec PlanCard + CreatePlanCard placeholder
- PlanCard : titre, chip meta (un seul à la fois), aperçu 3 étapes avec
  ◆ rotated comme bullet, footer usage + lien "Modifier →"
- Le plan le plus utilisé reçoit le badge "✦ Le plus utilisé" (rubis-glow
  + Sparkles), les autres gardent leur label de tonalité (Doux / Standard
  / Ferme / Strict). Pas de "PLAN PAR DÉFAUT" partout — info tautologique
  vu que les 4 plans seedés sont des défauts.
- Chips de tonalité adoucis (bg-cream-2 ou rubis-glow, plus de fills lourds)
- Skeleton pulsé pendant le fetch

Éditeur /plans/$slug (cf. wireframe 3.2, route _app/plans_.$slug pour
escape la layout parent) :
- Header : eyebrow humeur + nom + compteur d'usage + boutons Dupliquer
  (V2) / Enregistrer (fonctionnel, désactivé tant que pas de changements)
- Layout 2-col responsive (1fr / 1.4fr) :
  · Gauche : cadence — list de StepCard cliquables, état sélectionné avec
    border-rubis + shadow-rubis, "+ Ajouter une étape" disabled (V2)
  · Droite : éditeur d'email — Card avec subject (Input), body (Textarea
    mono 10 rows), grille de variables-chips
- Variable insertion fonctionnelle : clic = insertion au curseur via
  selectionStart/End du textarea, label FR + token mono ({{numero}})
- Bandeau warning rubis-glow quand l'étape est requiresManualValidation :
  "Validation manuelle obligatoire. L'email est généré en brouillon"
- Save fonctionnel : isDirty calculé via JSON.stringify, mutation PATCH
  /plans/:slug, invalidate cache plans.all + setQueryData detail, toast
- Sync state local ↔ serveur via useEffect sur plan.id+updatedAt

MSW :
- handlers/plans.ts : GET /plans (avec usageCount), GET /plans/:slug,
  PATCH /plans/:slug (validation Zod, recompose ids manquants)
- mockDb : findPlanBySlug, listPlansForOrg, updatePlan
- Calcul usageCount : factures du plan en statut != paid && != cancelled

Lib /plans.ts :
- TONE_LABELS : Amical / Standard / Ferme / Mise en demeure (FR)
- planMoodLabel + planOverallTone (humeur globale = ton de la dernière étape)
- TEMPLATE_VARIABLES : 5 variables avec token + label FR + preview

Bundle prod : 117.31 KB gzip core (stable). plans 2.06 KB gzip,
plans_._slug 3.28 KB gzip — la plus grosse route chunk vu sa complexité
(form + variables + state local).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:05:36 +02:00