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>
invoices.spec.ts (10 cas) :
- Création : 201 + rubisEarned=1 (bonus saisie) + status=pending
- Création client à la volée : si nom non matché + email fourni → client créé
- 422 client_email_required si pas d'email pour création à la volée
- Si planId fourni : RelanceTasks scheduled créées (assertion sur DB count)
- Numéro unique par org : 422 sur duplicate (testant l'exception handler)
- mark-paid idempotent : 2e appel ne re-bumpe pas rubisEarned ni org.rubis_count
- mark-paid annule les RelanceTasks scheduled (passe à cancelled)
- Cross-org : user B → 404 sur mark-paid d'une facture de A
- GET /invoices : pagination + meta total/page
- GET /invoices/counts : agrège par status
imports.spec.ts (6 cas) :
- POST /upload mock JSON : 1 batch + N drafts en pending, mock OCR rempli les fields
- Refus > 20 fichiers
- Validate transforme draft en Invoice + status=validated + invoiceId set
- Validate sur draft déjà processed → 409 draft_already_processed
- Skip → status=skipped, idempotent
- DELETE batch → CASCADE supprime les drafts
dashboard.spec.ts (6 cas) :
- KPIs zéros sur org vierge
- factureToRelance compte les invoices pending
- Après mark-paid : encaisseCents et rubisCount bumpent (org.rubisCount agrégé)
- Activity vide sur org sans actions
- Activity loggue invoice_paid après mark-paid (label dans le feed)
- Top-late liste les clients avec invoices actives en retard (dueDate < today)
Helper response.ts : `body<T>()` pour caster Tuyau strict response shapes (Tuyau type chaque code de statut comme une union, assertStatus ne narrow pas → on cast explicitement vers ApiOk<T>/ApiError/ApiConflict<T>).
clients.spec.ts (16 cas) :
- POST /clients : refus sans email (422 + field=email), refus SIRET ≠ 14 chiffres, création OK avec UUID + association org, doublon nom case-insensitive (409 + payload existing)
- GET /clients : isolation cross-org (user A ne voit pas les clients de B), withStats=1 enrichit (zéros sans factures), recherche q ILIKE
- Perms cross-org : user B → 404 sur GET/PATCH d'un client de A, l'objet ne bouge pas
plans.spec.ts (7 cas) :
- GET /plans : 4 plans pré-fournis avec steps préchargés, isolation cross-org (UUIDs disjoints entre A et B)
- GET /plans/:slug : steps ordonnés, 404 si inconnu
- PATCH /plans/:slug : remplace les steps en bloc dans une tx, rejette tone invalide, cross-org (B édite SA copie sans toucher celle de A)
Setup :
- .env.test étoffé : DRIVE_DISK=fs, MAIL_DRIVER=smtp local, OCR_PROVIDER=mock. Réutilise la DB rubis_dev avec global transactions par test (rollback auto, isolation parfaite).
- Schedulers (relance + checkin) détectent NODE_ENV=test et skippent BullMQ.add. Les tasks DB sont quand même créées (utiles pour assertions) mais aucun job orphelin n'arrive en Redis après rollback de tx.
- helpers/auth.ts : factory createTestUser() qui crée org + user + 4 plans pré-fournis dans une tx, retourne user/org/accessToken/bearer header. createTwoOrgs() pour les tests cross-org à venir.
Tests fonctionnels auth (tests/functional/auth.spec.ts) :
- Signup : crée user + org + 4 plans pré-fournis (vérifie les slugs), refuse email mal formé / password court / email déjà pris
- Login : émet AuthSession avec credentials valides, rejette mauvais password / email inconnu
- Bearer auth : 401 sans token, 401 avec token bidon, 200 avec token valide
- Logout : révoque le token courant, requêtes suivantes en 401
- Onboarding : PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom, idempotent ensuite
Pour lancer : `pnpm -F api test`
Monorepo Turborepo (pnpm workspaces) avec 3 packages :
- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
• TanStack Router (file-based, auto code-splitting), Query, Form
• Radix primitives bruts + CVA + clsx + tailwind-merge
• MSW pour mocker l'API tant qu'Adonis n'est pas branché
• Polices Bricolage Grotesque + Inter self-hostées via fontsource
• Tokens marque (rubis, cream, ink) exposés via @theme
• Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
• Route /login full flow : TanStack Form + Zod + mutation Query
- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
• Auth access tokens (Bearer) — cf. ADR-017
• Tuyau core déjà câblé pour la génération de types
• Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
• Minimal — uniquement le pont front ↔ back
- packages/shared : types TS + schemas Zod + constantes
• Source unique de vérité partagée api ↔ web
• Domaines : User, Org, Auth, Client, Invoice, Plan
Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.
CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.
Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>