75 Commits

Author SHA1 Message Date
ordinarthur
59f81879d8 test(e2e): tests Playwright multi-stack — vrai navigateur, DB isolée, Stripe mocké
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m39s
Build & Deploy API / build-and-deploy (push) Successful in 2m30s
Build & Deploy Web / build-and-deploy (push) Successful in 1m21s
Ajoute une couche end-to-end où un Chromium drive la SPA + API ensemble
contre une DB Postgres séparée, avec Stripe entièrement mocké au niveau
API. 6 scénarios couverts (signup + onboarding + 4 sur le billing trial).

Architecture :
  - DB `rubis_test_e2e` séparée, TRUNCATE entre tests (~50 ms reset)
  - Routes test-only `/__test__/*` gated par NODE_ENV=test_e2e
    (reset, install Stripe mock, fire webhook, lire org state, last-org)
  - Stripe mocké via __setStripeForTests — pas d'appel réseau
  - Playwright spawn API + SPA automatiquement (webServer config)
  - CORS étendu à test_e2e pour le cross-origin localhost:5173 → :3333

Scénarios :
  - signup.spec.ts : signup → onboarding 3 étapes → dashboard (assert rubis hero)
  - billing-trial.spec.ts :
      • démarrer essai 14j → redirect Stripe Checkout (mock)
      • fallback Free 2 factures continue l'onboarding
      • webhook checkout.completed → org en trialing + trial_ends_at
      • retour ?trial=cancel après abandon
      • inspection DB : stripeCustomerId posé après start-trial

Scripts :
  - pnpm e2e          (headless)
  - pnpm e2e:headed   (Chromium visible)
  - pnpm e2e:ui       (mode interactif Playwright)
  - pnpm e2e:setup    (crée + migre rubis_test_e2e via docker exec)

Documentation : docs/tech/e2e-tests.md — architecture, scénarios,
extensions, CI, troubleshooting.

Limites assumées :
  - L'UI Stripe Checkout (3DS, formulaire CB) n'est pas testée — externe.
    Pour ça : playbook manuel docs/tech/stripe-trial-e2e-playbook.md.
  - Le rendu du banner "Essai Pro" n'est pas asserté en E2E à cause de
    TanStack Query staleTime — couvert par les tests vitest à la place.

État global du chantier billing : 127 tests japa + 6 Playwright + 11
vitest = couverture multi-niveaux.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:58:51 +02:00
ordinarthur
0db7ff877c fix(clients): aligne adresse structurée sur convention Lucid snakeCase
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m17s
La migration 1778800000100_enrich_clients_for_invoicing avait créé les
colonnes `address_line1` et `address_line2` (sans underscore avant le
chiffre), mais Lucid v22 auto-snake-case `addressLine1` → `address_line_1`
côté écriture. Résultat : tous les INSERT dans `clients` cassaient avec
`column "address_line_1" of relation "clients" does not exist`.

Bug latent — surfacé en lançant la suite functional complète après
`node ace migration:run`. Affectait 20 tests Clients/Dashboard/Invoices.

Fix : migration de rename qui aligne `address_line1` → `address_line_1`
et `address_line2` → `address_line_2`. Le `RENAME COLUMN` préserve les
données existantes en prod. Modèle Client simplifié (les déclarations
manuelles ne sont plus nécessaires depuis que `schema.ts` les a régen).

Bonus : fix du test `invoices.spec.ts` "liste paginée + meta total/page"
qui créait 3 factures, ce qui dépasse la nouvelle limite Free 2 (ADR-023).
Posé un `gracePeriodEndsAt` futur sur l'org du test pour bypass le quota.

État après ce commit : 127 tests verts (60 unit + 67 functional).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:10:49 +02:00
ordinarthur
094c26059f test(billing): tests E2E HTTP du tunnel essai 14 j + playbook Stripe test mode
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
Ajoute 16 tests E2E qui hit les vraies routes `/api/v1/billing/*` à
travers le middleware auth, les validators et la persistance DB.
Complémentaire des 60 tests unitaires sur les services.

Suites couvertes :
  - POST /start-trial : 200 happy path, customer Stripe réutilisé,
    409 trial déjà consommé (2 garde-fous), 401 sans Bearer
  - GET  /subscription : expose inTrial + trialEndsAt, garde-fou
    trial_ends_at passé
  - POST /webhook : checkout.completed, subscription.updated trialing→active,
    trial_will_end → enqueue recap (avec spy), payment_failed → past_due,
    subscription.deleted → free + trial_ends_at conservé
  - Idempotence : 2× le même event = même état final
  - Event type inconnu → 200 silencieux (pas de DB write)
  - 400 si stripe-signature absent / signature invalide

Helpers de test :
  - installFullStripeMock(opts) → mock complet : customers, prices,
    checkout, billingPortal, subscriptions, webhooks. Avec
    passThroughWebhook qui bypass la vérif signature pour tester
    le routing applicatif sans signer manuellement chaque payload.

env.test : STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET dummy +
WEB_URL/LANDING_URL.

Documentation : docs/tech/stripe-trial-e2e-playbook.md — playbook
manuel pour valider en mode Stripe test (5 scénarios : happy path
3DS, carte refusée au prélèvement, annulation Customer Portal,
re-trial bloqué, fallback Free). Utilise Stripe Test Clocks pour
fast-forward sans attendre 14 jours réels.

Total après ce commit : 76 tests sur la chaîne billing (60 unit + 16 E2E).
Les cas Stripe-side (3DS UI réel, prélèvement effectif J+14) restent
à valider manuellement via le playbook avant le go-live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:01:16 +02:00
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
cb1195ab73 fix(api): TCP keepalive + pool tuning sur la connexion PG
Le firewall entre pod K3s et LXC PG coupe silencieusement les connexions
idle (« Connection terminated unexpectedly » remonté par Sentry). On
active `keepAlive` sur le socket pg et on recycle les connexions idle
du pool plus vite (idleTimeoutMillis: 30 s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 13:29:24 +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
ab07cd4a3b feat(invoices): génération PDF native via @react-pdf/renderer (Phase 2)
Implémente les 4 thèmes de factures (Classique, Moderne, Minimal,
Élégant) en composants React PDF et remplace les stubs Phase 1 par la
vraie génération + upload MinIO.

Templates (app/pdf-templates/)
- common.tsx : props partagées, formatters fr-FR (cents → euros,
  dates longues, taux TVA), palette neutre.
- classique : sobre, header texte centré, filets fins. Pour les
  cabinets et professions réglementées.
- moderne : bandeau coloré pleine largeur, logo dans le bandeau.
  Pour les agences et studios.
- minimal : noir et blanc, aéré, accent uniquement sur le numéro.
  Pour les indépendants et les designers.
- elegant : Times Roman, filets fins, titre centré encadré, italique
  sur le pied légal. Pour les boutiques premium.
- index.tsx : dispatcher slug → composant + renderInvoiceToBuffer.

Génération
- media_storage : nouveau scope `invoice-pdf` (`invoices/<orgId>/<uuid>.pdf`)
  et fonction `uploadBuffer(buffer, scope, subPath?)` pour stocker les
  buffers générés en mémoire (vs. uploads multipart existants).
- invoice_pdf : `generateInvoicePdf` rend + upload, `previewInvoicePdf`
  rend en Buffer pour stream HTTP direct.
- InvoicesController.pdf : lazy regenerate si pdf_storage_key est null
  sur une facture native (cas où la génération initiale a échoué).
- InvoicesController.previewPdf : synthétise un clientSnapshot depuis
  les données live, passe dans le pipeline standard.
- InvoicesController.storeNative : appelle la vraie génération en
  post-commit, log + continue si échec.

Conformité Factur-X (V1.5) : la structure de génération est un
point d'extension Buffer → Buffer ; l'injection d'un XML CII en
pièce jointe PDF sera ajoutée sans toucher aux templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:16:45 +02:00
ordinarthur
e0b47ddfdc feat(invoices): éditeur de factures natif — data model + API (Phase 1)
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.

Data model
- organizations.invoice_settings (JSONB) : thème par défaut, accent color,
  préfixe et compteur de numérotation, mentions légales (pénalités,
  escompte), identité émetteur (SIREN/SIRET/TVA intra/RCS/capital), RIB.
- clients enrichi : SIREN, TVA intra, adresse structurée (lines/zip/city
  /country). Le champ address legacy reste pour les clients pré-feature.
- invoices enrichi : lines (JSONB), client_snapshot + issuer_snapshot
  figés à l'émission, amount_ht/tva, tva_breakdown, payment_terms_days,
  theme_slug + theme_accent_color, is_native, sequence_number (unique
  per org), pdf_generated_at.

API
- GET/PATCH /organizations/me/invoice-settings (resolveInvoiceSettings)
- GET /invoice-themes (4 thèmes : classique, moderne, minimal, élégant)
- POST /invoices/native (séquence strict allouée en transaction,
  totaux recalculés serveur, snapshots immuables)
- POST /invoices/preview-pdf (stream PDF sans persister, stub Phase 1)

Le rendu PDF lui-même (@react-pdf/renderer + templates) arrive en
Phase 2 ; le storeNative crée bien la facture mais pdf_storage_key
reste null jusqu'à Phase 2. Conformité Factur-X visée pour V1.5
(Q3-Q4 2026, avant l'échéance d'émission TPE-PME au 1er sept 2027).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:07:45 +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
919ebfe755 feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m18s
Première moitié de la feature marque blanche : la machinerie complète qui
permet à un compte Business d'envoyer ses emails de relance avec son
propre logo, ses propres couleurs et son nom comme expéditeur, à la place
du branding Rubis.

Architecture :

- Nouvelle colonne JSONB `organizations.brand_settings` (12 tokens
  customisables : logo, senderName, et 10 couleurs — primary, banner,
  body bg, card bg, text, text muted, border, link, button text).
  Null = palette Rubis intacte. Validation hex stricte (#RRGGBB).

- Service `#services/brand` avec `resolveBrandTokens(org)` qui merge
  defaults + overrides en respectant le plan (couleurs/logo = Business
  only ; senderName = cascade pour tous les plans). Mergeurs avec
  sémantique "null = reset au default sur ce champ précis" pour les
  patches partiels.

- Service mutualisé `#services/media_storage` qui remplace l'ancien
  `blog_uploads.ts`. Scopes `blog` (4 MB, jpg/png/webp) et `brand-logo`
  (1 MB, + svg accepté). Cleanup automatique du logo précédent lors
  d'un remplacement (pas de versioning — la conv produit est "on écrase").

- Controller `BrandController` (5 endpoints) + middleware
  `AssertBusinessPlanMiddleware` qui throw 403 `business_plan_required`
  (code matché par le SPA pour l'upsell card).

- Refactor des 3 templates mail (relance, payment thanks, checkin) +
  layout commun pour accepter `tokens: BrandTokens` en prop. Le
  dispatcher résout les tokens per-org pour relance + remerciement
  (= user → client, branded), et passe `DEFAULT_BRAND` au checkin
  (= Rubis → user, toujours Rubis-branded).

- Routes publiques pour le logo : `/api/v1/uploads/brand-logos/:filename`
  (sans auth, cache immutable, X-Content-Type-Options: nosniff pour les SVG).

UI self-service arrive dans la prochaine version (v1.12.0). En attendant,
un compte Business peut être configuré via Bruno / API directe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:37:07 +02:00
ordinarthur
b2dd991c58 fix(blog/admin): accept upload URLs (absolute + relative /uploads paths)
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m15s
L'upload renvoie maintenant une URL absolue construite depuis APP_URL
(`https://app.rubis.pro/api/v1/uploads/blog/{uuid}.{ext}`), pour que la
landing publique l'affiche directement en <img src> sans absolutize.

Le validator post (createPostValidator + updatePostValidator) accepte :
* Les URLs HTTPS absolues (image externe ou notre upload absolutisé)
* Les paths relatifs `/api/v1/uploads/...` (rétro-compat sécurité — si
  une URL relative arrive d'une autre source, on la laisse passer plutôt
  que 422 sur un champ qui résout côté client)

Bug initial : POST /api/v1/admin/uploads renvoyait `/api/v1/uploads/...`
(relatif), puis le PATCH /admin/posts/:id rejetait ce path en 422 car
`vine.string().url()` exige une URL absolue. Cause = double oubli (path
relatif côté upload + validator strict).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:54: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
f59b11f836 chore(debug): endpoint /api/v1/_debug/sentry-test pour valider Sentry E2E
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m17s
Endpoint qui throw délibérément pour tester :
- Capture côté Sentry (handler.ts:report)
- Release tag (APP_VERSION = sha git)
- Sourcemaps désobfusquées
- Tags { url=pattern, method, status }
- User context (si auth présente)

Gardé par NODE_ENV !== 'production' OU DEBUG_SENTRY_TEST=true.
Sur prod, activer le temps du test via :

  kubectl -n rubis set env deploy/rubis-api DEBUG_SENTRY_TEST=true
  curl -i https://app.rubis.pro/api/v1/_debug/sentry-test
  # → 500, vérifier Sentry
  kubectl -n rubis set env deploy/rubis-api DEBUG_SENTRY_TEST-

À retirer (revert ce commit) une fois l'intégration Sentry validée.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:50:10 +02:00
ordinarthur
f33b2dd319 feat(observability): Sentry monitoring API + Web (ADR-024)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m9s
Build & Deploy API / build-and-deploy (push) Successful in 2m2s
Intégration Sentry SaaS pour error monitoring + replay sur les 2 apps.

API (apps/api) :
- start/sentry.ts : init au plus tôt dans bin/server.ts (avant Ignitor)
  pour capturer les erreurs de bootstrap. No-op si SENTRY_DSN_API absent.
- app/exceptions/handler.ts:report : captureException sur les 5xx avec
  tags { url, method, status } et user.id (PII minimisée). 4xx filtrés
  par beforeSend dans start/sentry.ts (validation, auth invalide = bruit).
- start/env.ts : SENTRY_DSN_API + APP_VERSION optionnels.
- bin/server.ts : import #start/sentry en 1er.
- @sentry/node + @sentry/profiling-node ajoutés au package.json.

Web (apps/web) :
- src/lib/sentry.ts : init au plus tôt dans main.tsx, BrowserTracing +
  Replay (0% session, 100% sur erreur — économie quota free tier).
  maskAllText + blockAllMedia pour privacy par défaut.
- src/lib/auth.ts : Sentry.setUser({ id }) au login, setUser(null) au
  logout (corrélation cross-stack des erreurs front avec un user).
- src/main.tsx : ErrorBoundary autour de l'app avec FallbackError UX.
- vite.config.ts : @sentry/vite-plugin uploads les sourcemaps + les
  SUPPRIME du dist/ final (filesToDeleteAfterUpload) pour ne pas leak
  le code source via nginx en prod. Helper resolveAppVersion() pour
  injecter le sha git en dev (le shell n'étant pas évaluable dans .env).
- src/lib/env.ts : VITE_SENTRY_DSN_WEB + VITE_APP_VERSION optionnels.
- .env.development : VITE_SENTRY_DSN_WEB (préfixé correctement pour
  être exposé par Vite — l'ancienne SENTRY_DSN ne marchait pas).
- @sentry/react + @sentry/vite-plugin ajoutés au package.json.

CI Gitea :
- deploy-api.yml : kubectl set env APP_VERSION=${{ github.sha }}
  runtime → release Sentry trackable au commit pour l'API.
- deploy-web.yml : build-args VITE_SENTRY_DSN_WEB, VITE_APP_VERSION,
  SENTRY_AUTH_TOKEN, SENTRY_ORG injectés depuis les secrets Gitea.
- Dockerfile.web : ARG correspondants + propagation au stage build.

Privacy / sécurité (cf. ADR-024) :
- captureException tags : ctx.route?.pattern (pas l'URL réelle) →
  les codes OAuth (?code=...) et tokens de check-in n'apparaissent
  jamais dans les tags Sentry indexés.
- Sentry user context = user.id UUID seulement, pas d'email/nom.
- Sourcemaps en prod : uploadées à Sentry, supprimées du bundle.
- 4xx filtrées en amont (beforeSend) ET en aval (handler.ts:report).
- DSN public (by-design) commit-able, AUTH_TOKEN secret CI uniquement.

Sample rates (free tier 5K events / 50 replays par mois) :
- traces : 10% prod, 100% dev
- profiles : 100% (sampled par traces)
- replay session : 0% (économie quota)
- replay sur erreur : 100% (debug post-mortem)

Pré-requis runtime à configurer hors-repo :
- Secret K3s rubis-app-secrets : SENTRY_DSN_API
- Secrets Gitea Actions : SENTRY_DSN_WEB, SENTRY_AUTH_TOKEN, SENTRY_ORG

ADR-024 logué dans docs/decisions.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:38:12 +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
eb248c98b8 style(mail): fond crème edge-to-edge (au lieu d'un container blanc)
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m8s
Avant : body crème + container blanc avec border-radius + bordure → effet
"card flottante" qui laissait des marges crème en haut/bas et un fond
blanc pour le contenu. Sur iPhone Mail, cette structure créait un gap
mal rendu entre la frame Mail.app et le header rubis-deep.

Maintenant :
- bodyStyle.padding 24px 0 → 0 (rubis-deep colle au haut de la zone mail)
- containerStyle background blanc → crème (toute la zone email est crème,
  cohérente avec la palette)
- containerStyle borderRadius + border supprimés (edge-to-edge)
- invoiceCardStyle (checkin) + summaryCardStyle (relance) passent en
  blanc pour se détacher du nouveau fond crème
- Dark mode CSS : .rubis-container override aussi mis à crème

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:55:47 +02:00
ordinarthur
1bb0c7166b fix(mail): force light color-scheme pour empêcher l'auto-inversion iOS
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Sur iPhone Mail.app en dark mode, iOS inverse automatiquement les
couleurs de l'email : header rubis-deep (#771328) devenait rose
pâle, fond crème devenait noir, texte sombre devenait blanc.

Fix appliqué dans le _layout.tsx (donc impacte checkin + relance) :
- Ajoute meta `color-scheme: light only` + `supported-color-schemes`
  → signal aux clients mail (iOS Mail, Gmail mobile, Yahoo)
  qu'on ne souhaite PAS d'auto-dark-mode
- Ajoute style block avec :root color-scheme + overrides
  Outlook.com dark mode ([data-ogsc] / [data-ogsb])
- Ajoute className sur Body / Container / header / footer pour
  permettre le ciblage CSS dark-mode-resistant

Couvre : iOS Mail, Apple Mail macOS, Gmail mobile dark, Outlook.com.
Aucun impact sur les clients qui ne font pas d'inversion (Outlook
desktop, Thunderbird, etc.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:42:57 +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
ab75f1f979 fix(checkin): bump invoice.status pending → awaiting_user_confirmation
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m6s
Bug V1 documenté dans flow.md mais jamais corrigé : le job send_checkin_job
envoyait l'email + marquait la CheckinTask `sent`, mais ne touchait pas le
statut de la facture. Conséquence : l'user reçoit le mail check-in dans sa
boîte mais la modale in-app au refresh ne l'affiche pas (la modale liste
uniquement les `awaiting_user_confirmation` côté DB).

Fix : après l'envoi mail OK et le mark CheckinTask=sent, on bump
`Invoice.status = 'awaiting_user_confirmation'` SI elle est encore
en `pending`. Pas de bump si entre temps :
  - mark-paid (status=paid)
  - litigation/cancelled (transitions manuelles)
  - in_relance (impossible mais safe)

Doc flow.md mise à jour pour refléter le nouveau comportement (effets
de la transition pending → awaiting + déprécation de la note "TODO V1.5").

Pour les factures existantes en prod qui ont déjà reçu le mail mais
restent en `pending` (cas pré-fix) : backfill manuel via SQL :

  UPDATE invoices SET status = 'awaiting_user_confirmation'
  WHERE status = 'pending'
    AND id IN (
      SELECT invoice_id FROM checkin_tasks WHERE status = 'sent'
    );

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:42:52 +02:00
ordinarthur
023f08c261 feat(api): commande ace billing:scenario pour tester les états billing
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Force un état billing sur l'org d'un user pour tester rapidement chaque
comportement UI/enforcement sans passer par Stripe ni attendre 3 mois.

Usage :
  node ace billing:scenario --email <user> --scenario <name>

Scénarios :
  • status         : ne touche rien, affiche juste l'état courant
  • fresh          : reset signup neuf (free + grace 3 mois)
  • grace-expired  : free, grace terminée, ≤ 5 actives → OK
  • limit-reached  : free, grace terminée, force 5 actives → bloqué (402)
  • pro            : pro mensuel actif, fake IDs si pas de vrais
  • pro-cancelling : pro + cancel_at_period_end=true → bandeau ANNULÉ
  • pro-past-due   : pro + status=past_due → warning UI
  • business       : business mensuel actif

Sécurité : préserve les VRAIS Stripe IDs s'ils existent (= l'org a
déjà payé). Génère des fake `cus_test_FAKE_*` / `sub_test_FAKE_*`
seulement si NULL — ne pas écraser une vraie souscription.

Le command affiche un récap compact à chaque exécution :
  - plan / grace / Stripe IDs / status / cancel_at
  - factures actives vs limite
  - création autorisée ou non + raison

Pour tester un comportement côté UI :
  1. Lance le scénario
  2. Reload /parametres/abonnement et /factures
  3. Vérifie le rendu (bandeau cancel, blocage import, etc.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:36:06 +02:00
ordinarthur
031b8cc062 fix(billing): détecte aussi cancel_at (Customer Portal) + reactivate sans conflit
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Bug : le Stripe Customer Portal n'utilise pas `cancel_at_period_end:true`
mais `cancel_at:<timestamp>` pour scheduler l'annulation. Notre webhook
ne lisait que le booléen → l'annulation via portail n'était pas remontée
côté DB, l'UI ne montrait jamais le bandeau "annulé".

Webhook handler :
  - Détecte l'annulation via EITHER `cancel_at_period_end` OR `cancel_at`
    et unifie en un seul booléen `cancelAtPeriodEnd` côté org.

Endpoint /reactivate :
  - Stripe REFUSE qu'on passe `cancel_at_period_end:false` ET `cancel_at:null`
    dans le même update ("Please pass in only one"). On retrieve d'abord
    la sub pour savoir laquelle des 2 mécaniques est active, puis on clear
    uniquement celle-là.

Logs enrichis : `cancelAtPeriodEnd` et `cancelAt` désormais loggés à
chaque `applySubscriptionToOrg` pour que le diagnostic soit immédiat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:18:18 +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
4dcd85f912 test(billing): unit tests backend (17) + frontend (7)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 26s
Build & Deploy API / build-and-deploy (push) Successful in 1m14s
Backend (`apps/api/tests/unit/billing.spec.ts`) — 17 tests :
  - PLAN_CAPS sanity : Free 5 invoices/1 user, Pro illimité, Business 5 sièges
  - countActiveInvoices :
      • compte les 4 statuts actifs (pending, awaiting, in_relance, litigation)
      • exclut paid + cancelled
      • isolation par org (ne fuit pas entre orgs)
  - canCreateInvoices :
      • Free + grace period → autorisé même à 50+ actives
      • Free post-grace + 4 actives + delta=1 → autorisé (≤ limite)
      • Free post-grace + 5 actives + delta=1 → BLOQUÉ + bonne raison/limit/current
      • Free post-grace + 3 actives + delta=3 → BLOQUÉ (over par batch)
      • Pro + Business → toujours autorisé
      • paid n'occupe pas de slot (5 paid + delta=5 → autorisé)
  - getOrgSubscriptionState :
      • inGracePeriod=true quand date future
      • inGracePeriod=false quand date passée
      • Pro reflète subscription_status / billing_cycle / current_period_end
      • activeInvoicesCount inclut bien les 4 statuts

Frontend (`apps/web/src/lib/billing.test.tsx`) — 7 tests :
  - useSubscription : appelle /billing/subscription, retourne le state
  - useIsAtFreeLimit :
      • false en loading
      • false sur Pro avec 200 factures
      • false en grace period même si activeCount > limit (12)
      • true sur Free post-grace + activeCount = limit (5/5)
      • true sur Free post-grace + activeCount > limit (8/5)
      • false sur Free post-grace + activeCount < limit (4/5)

Setup :
  - vitest.config.ts : ajout de `env: {VITE_API_URL, ...}` pour stub
    les variables exigées par src/lib/env.ts au chargement (sinon plante
    au boot des tests).
  - Mock vi.spyOn(api, "get") pour éviter les vraies requêtes HTTP.
  - QueryClient avec retry:false pour fail-fast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:43:40 +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
b96b62aab6 feat(seed): génération PDF cohérente par facture via @react-pdf/renderer
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 57s
Build & Deploy API / build-and-deploy (push) Successful in 1m40s
Chaque facture seedée a maintenant son propre PDF dont le contenu
matche exactement les meta DB (vendeur = org du user, client = client
réel, numéro / dates / montant cohérents). Plus de réutilisation
round-robin de PDFs disque non-cohérents.

Stack :
  - @react-pdf/renderer : composants React déclaratifs, StyleSheet
    inspiré du SPA (mêmes tokens couleur Rubis), même mental model que
    le frontend.
  - InvoiceDocument décomposé en sous-composants Header / Addresses /
    ItemsTable / Totals / Footer pour itération facile.
  - Items générés depuis un pool B2B (conseil, dev, audit, formation,
    livraison, photo, …) avec quantités/prix unitaires qui s'ajustent
    pour que la somme matche le total TTC stocké.

Le command `seed:demo --reset` :
  - wipe les invoice-pdfs/{orgId}/* sur MinIO (paginé)
  - re-génère 227 PDFs (27 actionnables + 200 historiques)
  - CA cumulé paid ≈ 400 K€ pile sur la cible

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:59:55 +02:00
ordinarthur
040e787ee5 chore(seed): rendre seed:demo utilisable en prod
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 58s
- Dockerfile.api : copie `assets/test-invoices/` dans l'image (les 27
  PDFs servent au seed démo, ~80KB, négligeable).
- factories.ts : ajout d'un 3e candidat de chemin pour couvrir le
  contexte prod où la commande tourne depuis `/app/apps/api/build`.

Permet de peupler une org démo en prod via :
  kubectl -n rubis exec -it deploy/rubis-api -- \\
    sh -c 'cd /app/apps/api/build && node ace.js seed:demo \\
      --email <user> --reset --org-name="<nom>"'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:41:15 +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
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
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
32fcb02108 feat(api): factories de démo + commande seed:demo --email pour peupler une org
Factories réutilisables (database/factories.ts) :
- makeClient — un client à partir de 8 templates FR (Boulangerie Martin,
  Maçonnerie Dupont, etc.) avec contact/SIRET/adresse réalistes
- makeInvoice — une facture avec status driving les dates et le rubis
  earned (pending = future, in_relance = échue récente, paid = paidAt
  cohérent, etc.)
- makeActivityForInvoice — events alignés sur le statut (import/relance/paid)
- seedDemoOrg — recette V1 : 8 clients + 15 factures réparties sur 5
  statuts (5 paid sur 6 mois, 4 in_relance, 2 awaiting_user_confirmation,
  3 pending, 1 litigation) → fait vivre dashboard, factures et DSO

Commande Ace seed:demo
- Args : --email <email> (obligatoire), --reset (wipe avant), --orgName
- Flow : trouve user, configure son org (nom + bucket), provision les
  4 plans par défaut (idempotent), seed la data, met à jour rubis_count
- Pose une signature email par défaut sur le user si vide
- Tout en transaction : pas d'état inconsistant si une étape plante

Usage :
  node ace seed:demo --email arthurbarre.js@gmail.com
  node ace seed:demo --email ... --reset --orgName="Maçonnerie Dupont"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:57:41 +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
2c2724c634 fix(clients): retourner les vraies factures du client (TODO restant)
Le détail client servait `invoices: []` en hardcoded — la liste sur
/clients/:id était toujours vide. On préload la relation client + plan
sur les factures de l'org filtrées par client_id, on trie par priorité
de statut puis échéance, et on serialize via InvoiceTransformer (même
shape que GET /invoices, donc le SPA peut réutiliser InvoiceListItem).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:43:16 +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
dd93249362 refactor(deploy): split monolithique en 2 services (rubis-web nginx + rubis-api Node)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m16s
Build & Deploy API / build-and-deploy (push) Successful in 1m32s
Avant : une seule image (Dockerfile.app) qui bundle AdonisJS + SPA static.
Après : deux images, deux deployments, deux workflows CI avec path filters
indépendants.

Architecture
- rubis-web (NodePort 30110, exposé via Traefik)
  · nginx-alpine + SPA Vite dist + nginx.conf
  · sert /assets/* (cache 1y immutable), / (try_files index.html SPA fallback)
  · reverse-proxy /api/* → rubis-api.rubis.svc.cluster.local:3333
- rubis-api (ClusterIP, accessible uniquement depuis le cluster)
  · AdonisJS V7 + workers BullMQ dans le même process
  · init-container migrate (idempotent, depuis build/)
  · /api/v1/health pour les probes K3s + healthcheck Docker
- rubis-redis (ClusterIP, inchangé)

Bénéfices
- Build/deploy indépendants : changement front ne reconstruit pas l'API,
  changement API ne reconstruit pas le SPA
- nginx en frontal donne du gzip + cache long sur les assets fingerprintés
- API n'expose plus de surface publique (defense in depth)
- Routes plus simples : on retire le wildcard SPA fallback dans
  start/routes.ts (nginx s'en charge), on retire @adonisjs/static aurait
  été cohérent mais on le garde pour minimiser les diffs

Files
- Dockerfile.api (replaces Dockerfile.app, Node-only)
- Dockerfile.web (new, nginx)
- apps/web/nginx.conf (new)
- k3s/app/api.yml (replaces deployment.yml + service.yml, ClusterIP)
- k3s/app/web.yml (new, NodePort 30110)
- .gitea/workflows/deploy-{api,web}.yml (replaces deploy-app.yml)
- /api/v1/health route ajoutée

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:58:25 +02:00
ordinarthur
57907a5d68 fix(deploy): contourner @poppinss/ts-exec via tsx pour le build API
Some checks failed
Build & Deploy App / build-and-deploy (push) Failing after 30s
Le CI plantait avec ERR_UNKNOWN_FILE_EXTENSION sur bin/console.ts —
le hook @poppinss/ts-exec (basé sur @swc/core) ne s'enregistre pas
à temps avant l'import du premier .ts dans certains environnements
de build (vu local M-series ET CI Gitea linux/amd64).

Solution : appeler ace via tsx (esbuild-based, gère nativement .ts
dès le démarrage). Plus fiable cross-platform.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:34:55 +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
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