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>
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>
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>
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>
- Retire le système i18n EN (apps/landing/src/i18n/, pages /en/*) ajouté
en 4f3417f. Source unique de copy dans src/copy.ts (FR uniquement).
Switcher de langue retiré du header, sitemap nettoyé des hreflang.
- Header : micro-baseline « Logiciel de relance de factures impayées »
sous le wordmark pour lever l'ambiguïté du nom (§1).
- CTA principal : « Lancer Rubis » → « Démarrer mon essai 14 jours »
avec sous-texte sur Hero / FinalCTA / Pricing (§5).
- Essai 30 j → 14 j sur landing + CGV §6.2 (§3).
- Blog promu en nav primaire avec label « Ressources » (§6).
Doc d'arbitrage : docs/tech/landing-optimisations.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Active Astro i18n avec `defaultLocale: fr` et `prefixDefaultLocale: false`
— les URLs FR restent canoniques à la racine, l'EN vit sous `/en/*` pour
ne pas casser le SEO existant.
Architecture :
- `src/i18n/{types,fr,en,index}.ts` — dico FR fait foi (Dict inféré),
EN doit matcher la shape ; helpers `getTranslations(locale)` et
`getAlternateUrl()` pour le language switcher.
- `Layout.astro` lit `Astro.currentLocale`, propage `locale` aux
composants React, set `<html lang>`, og:locale + alt, hreflang.
- `SiteHeader` expose un lien switcher FR↔EN qui préserve la page.
- Toutes les sections (Hero, Stats, Promise, HowItWorks, Gamification,
AutoBanking, Legal, Pricing, FAQ, FinalCTA, Footnotes, SiteFooter)
acceptent une prop `locale` et tirent leurs chaînes du dico.
Pages EN créées :
- `/en/` — home complète
- `/en/blog`, `/en/changelog` — chrome traduit, contenu reste dans la
langue de rédaction (les .md changelog + posts API sont FR)
- `/en/cgv`, `/en/mentions-legales`, `/en/confidentialite` — résumés
courts ; la version juridiquement contraignante reste la FR (droit
français, conformité GDPR/LCEN/LME).
Sitemap mis à jour avec entrées FR/EN + `xhtml:link rel="alternate"`.
Pas de détection auto via Accept-Language pour l'instant — le switcher
header suffit en V1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Setup PostHog côté landing — loader inline dans Layout.astro + tracking
de 5 events business côté browser :
- blog_article_viewed / blog_cta_clicked (funnel blog → app)
- pricing_pro_cta_clicked / pricing_plan_selected (intent upgrade)
- signup_cta_clicked (CTA hero/header/finalCTA, location-aware)
Vars PUBLIC_POSTHOG_* inlinées au build via build-arg CI
(POSTHOG_PROJECT_TOKEN, partagé avec apps/web). Token public phc_*,
safe à bake dans le bundle.
Au passage : supprime posthog-server.ts laissé par le wizard
(dead code, importait posthog-node qui n'est pas dans les deps).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 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>
Contexte pour les futures itérations agent : skill PostHog
integration-react-tanstack-router-file-based + rapport du wizard
(événements instrumentés, dashboards créés, next steps).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Annonce la connexion bancaire auto sur la landing pendant la fenêtre
KYC Powens. Trois touches cohérentes pour préparer le marché aux
plans Pro / Business :
- Nouvelle section AutoBanking entre Gamification et Legal : badge
"Bientôt disponible", h2 « Plus jamais besoin de répondre "C'est
payé" », 4 bénéfices clés (détection temps réel via Powens AISP,
toutes banques françaises, mode validation ou auto-pilote, lecture
seule), badge "Inclus sur Pro et Business", mention DSP2 / AISP
ACPR pour la conformité. Mock visuel d'email "Garage Lemoine a
payé F2026-0013 · 4 189,40 €" avec halo glow rubis, récap
client/facture/montant + check-list "facture payée · relances
annulées · remerciement envoyé · +1 rubis".
- FAQ : enrichit la 1re question (paiement hors plateforme) avec
une teaser vers la section AutoBanking, et ajoute une nouvelle
question dédiée « La connexion bancaire, c'est sécurisé ? Vous
pouvez bouger mon argent ? » avec réponse explicite sur le statut
AISP (lecture seule, DSP2, révocation 1 clic).
- Pricing : ajoute une feature dans la liste des inclus Pro avec
mention "à venir" pour la détection bancaire automatique.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
Le contexte "Nouveauté" sur 11 cartes à la suite devenait du bruit visuel
(toutes les anciennes versions ont été des nouveautés à un moment, hein).
Le pill de type est maintenant réservé à la version la plus récente — sur
les autres on garde seulement la chip de version + la date.
Pour compenser et ancrer le regard sur le dernier état du produit, la
carte "latest" gagne un glow rubis multi-couches :
- Ring serré 4 px en couleur rubis-glow (contour soft)
- Drop shadow proche teintée rubis (profondeur)
- Bloom large diffus (halo ambiant)
- Animation "respiration" 5 s ease-in-out infini (variation subtile sur
l'intensité du bloom)
- Désactivée si `prefers-reduced-motion: reduce` côté user.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Page Astro prerendered qui liste les versions livrées en reverse-chrono.
Contenu géré en MD files versionnés dans le repo via Astro content
collections (`src/content.config.ts`), pas de DB — chaque release =
1 fichier `src/content/changelog/<x.y.z>.md` ajouté en PR à côté du bump
de version applicatif.
11 entrées initiales (v1.0.0 → v1.10.0) couvrant le premier mois public :
lancement (OCR Mistral + plans par défaut + mode démo + Stripe), saisie
manuelle, SSO Google puis Microsoft, plans custom, templates email,
réécriture IA, insights, blog, marque blanche, remerciement automatique.
UI :
- Hero centré aligné DA landing (eyebrow rubis + h1 display + sub muted)
- 2 colonnes desktop : feed cartes (gauche) + sticky rail jump-nav (droite)
- Sur mobile/tablette : pas de rail, juste le feed
- Sticky rail : IntersectionObserver inline qui met en surbrillance la
version courante quand l'user scrolle
- Anchors `#1.4.0` partageables, cliquables depuis le chip de chaque carte
- Type pills colorés : feature (rubis solid), improvement (cream-2),
fix (line outline)
- Bullets losanges ◆ rubis cohérents avec le gem brand
SEO :
- `prerender = true` → HTML figé au build, LCP minimum
- JSON-LD WebPage avec mainEntity[TechArticle] par version → rich
snippets Google
- Flux RSS 2.0 à `/changelog/rss.xml` (prerendered aussi)
- Auto-discovery RSS ajoutée au Layout (à côté de celle du blog)
- Lien Changelog ajouté au SiteFooter à côté de Blog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Brand : suffixe `sur l'ongle` → `.pro` (attaché, muted, non italique).
Propagé partout via `<Brand withSuffix>` — header/footer landing,
topbar/sidebar SPA, login/signup/onboarding. Renforce l'identification
brand requise par Google OAuth (gem seul jugé trop générique).
- Hero : dots groupés avec leur label (inline-flex) — ne se baladent plus
en début de ligne au flex wrap. Ajout d'un topbar mock "Rubis.pro ·
Tableau de bord" en haut de la carte hero pour identifier le mock comme
un vrai dashboard. `DSO` → `DSO*`.
- Stats : "Trois chiffres qui devraient vous fâcher" → "Trois chiffres
exorbitants". Sous-titre remplacé par "Et vous faites sûrement partie
intégrante de ces enquêtes." (plus direct, moins gratuit).
- Promise : "Votre temps vaut plus que ça" → "Votre temps est plus
précieux". Réécriture de l'amorce ("votre boîte / lundis soirs" →
"votre entreprise / journées"). Ajout d'une ligne italique "Parfois
moins, si votre plan par défaut est bien réglé".
- HowItWorks : Step 01 — suppression de "à la caisse" et de ", RIB"
(l'OCR ne lit pas le RIB en V1). Step 03 — "La machine fait le reste"
→ "L'algorithme fait le reste".
- Gamification : suppression de "Pas un PDF abscons" (jargon inutile) et
de "Et oui, on garde un classement amical" (le classement n'est pas en
V1). `DSO` → `DSO*`.
- Footnotes : ajout de la définition DSO (Days Sales Outstanding) sous
celle d'OCR.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ajoute une 4e étape dans la section « Comment ça marche » qui
matérialise la fin heureuse du cycle : le client paye, Rubis envoie
automatiquement un mot court de remerciement (« Merci, paiement bien
reçu »).
Pourquoi c'est important côté pitch :
- Aligne le produit avec le principe brand « respectueux du client
final » (cf. CLAUDE.md). On n'est pas qu'un outil de pression — on
est aussi celui qui sait dire merci.
- Crée une attente positive de fin de cycle, qui s'enchaîne mieux
vers le compteur de rubis (déplacé du step 03 vers 04 pour servir
de récompense narrative à la boucle complète).
Modifs :
- En-tête : « Trois étapes → Quatre étapes » + ajustement du
sous-titre.
- Step 03 : retitré « Vous validez. La machine fait le reste. »
(le punchline « Et puis c'est tout » migre implicitement sur 04).
- Step 04 (nouveau) : email de remerciement + encart rubis.
- Nouveau ThankYouWidget : email card stylé Apple Mail (sender,
sujet, corps, badge « Envoyé automatiquement ») en tokens rubis
uniquement (pas de vert — interdit par brand).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Élimine ~130 ms du critical path LCP rapportés par l'audit Lighthouse :
- `build.inlineStylesheets: "always"` dans astro.config.mjs : la
feuille `_astro/Layout.<hash>.css` (~42 KiB) n'est plus une requête
séparée render-blocking, elle est inlinée dans le HTML. Coût : +42 KiB
par page prerenderée (≈10 KiB gzippé sur la wire). Gain : 80 ms FCP.
`"auto"` aurait été ignoré (Layout.css > 4 KiB du seuil interne).
- `<link rel="preload">` sur les 2 woff2 latin (Inter body + Bricolage
Grotesque display) dans Layout.astro. Casse la chaîne HTML→CSS→fonts
du network dependency tree (~50 ms gagnés). URLs résolues via Vite
`?url` import → hashing préservé entre les builds.
On ne preload que latin-wght-normal — les autres subsets (latin-ext,
vietnamese, cyrillic, greek) sont chargés à la demande et ne valent
pas le poids upfront pour un trafic FR/latin.
Vérifié build : `<link rel="stylesheet">` disparu du HTML rendu, 1
seul `<style>` inline présent, 2 `<link rel="preload">` avec les bons
hashes d'asset.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit SEO révélait deux pertes de valeur :
1. **Titres trop longs** (86 chars sur la home, 71 chars sur les articles).
Google tronque à ~60 chars dans le SERP. Le suffixe automatique
`— Rubis sur l'ongle` (20 chars) écrasait le message-clé.
Layout.astro fait maintenant un suffix smart :
* Si title <45 chars ET ne contient pas "Rubis" → suffix `— Rubis` (8 chars)
* Sinon → titre tel quel (la brand est déjà couverte par og:site_name +
JSON-LD publisher + le hostname rubis.pro visible dans la SERP).
Résultat : home 64 chars, article 51 chars, "Mentions légales" → 24 chars
avec suffix.
2. **Pas d'`og:image` sur les pages sans hero** (home, légal). Sur
LinkedIn/X/Slack, aucune preview image — perte d'engagement énorme.
Ajout d'un og-default.png 1200×630 (165 KB optimisé) dans
apps/landing/public/, monté par fallback par Layout.astro quand la
page ne fournit pas d'`ogImage` explicite. Twitter:card devient
toujours `summary_large_image`.
L'image a été générée via le nouvel outil HTML
docs/marketing/assets/og-default.html (clone de la mécanique du
linkedin-banner.html — clic sur "Télécharger PNG" et go).
Compose : brand + tagline + mock card 124 rubis + CTA rubis.pro.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Templates HTML stylés DA Rubis pour les 2 emails sortants — fini le
plain text moche.
apps/api/app/mails/
├── _brand.ts : tokens couleur + spacing partagés
├── _layout.tsx : squelette commun (header rubis-deep + footer)
├── checkin_email.tsx : email envoyé À L'USER avec 2 boutons CTA
│ Oui (rubis primary) / Non (outlined)
└── relance_email.tsx : email envoyé AU CLIENT, body texte du plan
+ card récap (numéro, montant, échéance,
badge retard rubis-deep)
Stack :
- @react-email/components + @react-email/render
- Tous les styles inline (compatible Gmail / Outlook / Apple Mail)
- HTML + plain text en fallback (anti-spam, accessibility)
mail_dispatcher.ts :
- sendRelanceEmail : .html(rendered) + .text(body)
- sendCheckinEmail : .html(rendered) + .text(body)
- daysLate calculé via clock.now (démo-aware)
send_test_email :
- Nouveau flag --template=checkin (default) | relance | plain pour
tester chaque rendu via Mailpit sans créer de vraie facture.
Brand & landing :
- "Rubis Sur l'Ongle" → "Rubis sur l'ongle" partout (config, mail,
PDF, Stripe appInfo)
- Nouvelle env var LANDING_URL (default https://rubis.arthurbarre.fr)
- Footer email rend "Rubis sur l'ongle" comme <a> rubis cliquable
vers la landing — l'user qui reçoit le mail connaît la marque
derrière l'envoi
- .env.example mis à jour avec LANDING_URL pour les autres devs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le client final qui reçoit la relance voyait "From: Rubis Sur l'Ongle"
alors qu'il connaît "Arthur Barré" (le patron de la TPE qui utilise
Rubis). Confusion garantie côté client → relance perçue comme spam.
Fix : `sendRelanceEmail` utilise maintenant comme display name "From" :
1. `organization.name` (en priorité — c'est le nom commercial connu
du client)
2. `user.fullName` (fallback si l'org n'a pas de nom posé)
3. `MAIL_FROM_NAME` env (dernier recours, "Rubis Sur l'Ongle" en prod)
L'adresse technique reste sur notre domaine vérifié (relances@arthurbarre.fr
→ SPF/DKIM Resend OK), seul le display name change.
Le mail check-in (envoyé À l'user, pas au client) garde "Rubis Sur l'Ongle"
comme display — c'est nous qui le notifions, c'est cohérent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug V1 documenté dans flow.md mais jamais corrigé : le job send_checkin_job
envoyait l'email + marquait la CheckinTask `sent`, mais ne touchait pas le
statut de la facture. Conséquence : l'user reçoit le mail check-in dans sa
boîte mais la modale in-app au refresh ne l'affiche pas (la modale liste
uniquement les `awaiting_user_confirmation` côté DB).
Fix : après l'envoi mail OK et le mark CheckinTask=sent, on bump
`Invoice.status = 'awaiting_user_confirmation'` SI elle est encore
en `pending`. Pas de bump si entre temps :
- mark-paid (status=paid)
- litigation/cancelled (transitions manuelles)
- in_relance (impossible mais safe)
Doc flow.md mise à jour pour refléter le nouveau comportement (effets
de la transition pending → awaiting + déprécation de la note "TODO V1.5").
Pour les factures existantes en prod qui ont déjà reçu le mail mais
restent en `pending` (cas pré-fix) : backfill manuel via SQL :
UPDATE invoices SET status = 'awaiting_user_confirmation'
WHERE status = 'pending'
AND id IN (
SELECT invoice_id FROM checkin_tasks WHERE status = 'sent'
);
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Force un état billing sur l'org d'un user pour tester rapidement chaque
comportement UI/enforcement sans passer par Stripe ni attendre 3 mois.
Usage :
node ace billing:scenario --email <user> --scenario <name>
Scénarios :
• status : ne touche rien, affiche juste l'état courant
• fresh : reset signup neuf (free + grace 3 mois)
• grace-expired : free, grace terminée, ≤ 5 actives → OK
• limit-reached : free, grace terminée, force 5 actives → bloqué (402)
• pro : pro mensuel actif, fake IDs si pas de vrais
• pro-cancelling : pro + cancel_at_period_end=true → bandeau ANNULÉ
• pro-past-due : pro + status=past_due → warning UI
• business : business mensuel actif
Sécurité : préserve les VRAIS Stripe IDs s'ils existent (= l'org a
déjà payé). Génère des fake `cus_test_FAKE_*` / `sub_test_FAKE_*`
seulement si NULL — ne pas écraser une vraie souscription.
Le command affiche un récap compact à chaque exécution :
- plan / grace / Stripe IDs / status / cancel_at
- factures actives vs limite
- création autorisée ou non + raison
Pour tester un comportement côté UI :
1. Lance le scénario
2. Reload /parametres/abonnement et /factures
3. Vérifie le rendu (bandeau cancel, blocage import, etc.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>