8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b0e6f83655 |
feat(billing): essai 14 j Pro avec CB à l'inscription (Stripe trial_period_days)
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> |
||
|
|
77fdb6af48 |
feat: email de remerciement automatique après confirmation de paiement
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> |
||
|
|
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>
|
||
|
|
68ed8f2ec6 |
feat(api): logs fichier en dev + traces du flow relance/mail
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
94263c6447 |
feat(api): check-in flow — email à l'user + endpoints publics paid/pending
Le check-in remplace l'intégration banking V1 (cf. CLAUDE.md → Glossaire) :
avant que la 1re relance ne parte, on demande à l'user "as-tu été payé ?"
via email, et il clique sur l'un des 2 liens publics.
Service checkin_token.ts : génération + hash SHA-256. 32 bytes random base64url, plain dans le mail, hash en DB (CheckinTask.token_hash unique).
Service checkin_scheduler.ts :
- scheduleCheckinForInvoice(invoice) : crée 1 CheckinTask à dueDate (now+1min si dueDate dans le passé). Idempotent par invoice — cancel les scheduled précédents avant.
- cancelCheckinForInvoice(invoiceId) : appelé par mark-paid pour stopper.
Job send_checkin_job.ts : worker queue 'checkins', skip si invoice paid/cancelled (no-op), construit l'URL avec le plain token (passé dans le payload du job, pas relu DB), appelle sendCheckinEmail.
mail_dispatcher.ts : sendCheckinEmail() — texte brut, destinataire = user (pas client !), 2 URLs (paid / pending), TTL 24h annoncé.
Controller CheckinController :
- GET /api/v1/checkin/:token/paid : status=answered + answer=paid + mark invoice paid (mêmes effets que POST /invoices/:id/mark-paid : rubis +1, ActivityEvent invoice_paid avec label "via check-in", cancelFutureRelances). Idempotent : 2e click → redirect "already_answered".
- GET /api/v1/checkin/:token/pending : status=answered + answer=still_pending. Les relances suivent leur cours.
- Validation : lookup hash, expiry (sentAt + 24h), redirects propres pour invalid / expired / already_answered.
Routes : nouveau group public `checkin` (PAS de middleware.auth) à côté du group auth, sous /api/v1.
Triggers branchés :
- InvoicesController.store et ImportBatchesController.validateDraft → scheduleCheckinForInvoice après création
- InvoicesController.markPaid → cancelCheckinForInvoice dans la tx
start/queue.ts : registerWorker('checkins', sendCheckinJob).
env : nouveau WEB_URL (URL du SPA pour redirects), default localhost:5173 en dev.
Bruno : nouveau dossier 08-Checkin avec doc complète du flow + 2 requêtes (paid / pending). var d'env `checkinToken` à remplir manuellement après avoir reçu l'email dans Mailpit.
|
||
|
|
a6b35dfe7a |
feat(api): RelanceTask + CheckinTask + worker BullMQ qui envoie les relances
Migrations :
- relance_tasks (uuid id, organization_id FK CASCADE [scope direct sans join], invoice_id FK CASCADE, plan_step_id FK RESTRICT, send_at, status ENUM scheduled/sent/cancelled/failed, sent_at, queue_job_id pour cancel via BullMQ.remove). Indexes (org,status), (invoice_id), (send_at).
- checkin_tasks (uuid id, org_id, invoice_id, send_at, token_hash unique [SHA-256 du HMAC, TTL 24h], status ENUM scheduled/sent/answered/expired, answer 'paid'|'still_pending'). Pas encore branché — flow check-in arrivera dans un commit séparé (cf. backend.md §13.3).
Schema rules : status enums + answer typés.
Models RelanceTask + CheckinTask avec belongsTo Invoice / PlanStep.
Service relance_scheduler.ts :
- scheduleRelancesForInvoice(invoice) : pour chaque step du plan, calcule sendAt = dueDate + offsetDays. Si sendAt < now (facture importée en retard), on programme à `now + 1min` plutôt que skip — l'utilisateur "rattrape" une dette de relance, l'envoi immédiat est cohérent. Crée la RelanceTask + enqueue BullMQ avec delay, retry 5x exponential, jobId = `relance:<taskId>` pour idempotency. Cancelle les tasks scheduled existantes avant de re-programmer (gestion changement de plan).
- cancelFutureRelances(invoiceId, trx) : appelé par mark-paid pour stopper la chaîne.
Service queue.ts :
- getQueue(name) singleton lazy par queue
- registerWorker(name, handler) avec concurrency 5, log failed/completed
- shutdownQueue() pour le terminating hook Adonis
start/queue.ts (preload) : registerWorker('relances', sendRelanceJob) seulement quand `app.getEnvironment() === 'web'` (pas en tests/REPL — pas de connexion Redis pendant Japa).
Job send_relance_job.ts :
- Idempotent : si task.status !== 'scheduled', no-op
- Hook critique : si invoice paid/cancelled entre-temps, task.status = cancelled
- Mise en demeure (step.requiresManualValidation) : on n'envoie PAS, on log un activity_event 'warning_drafted' (cf. CLAUDE.md → Principes : validation manuelle obligatoire)
- Sinon : sendRelanceEmail + task.status=sent + invoice.rubisEarned+1 + organizations.rubis_count+1 + activity_event 'relance_sent'. Si invoice.status='pending', passe en 'in_relance' (sortie de l'état silencieux).
Service mail_dispatcher.ts : sendRelanceEmail interpole step.subject/body via mini moteur Mustache-like (renderTemplate, services/template.ts) avec {{client.name}}/{{numero}}/{{amount}}/{{dueDate}}/{{signature}}, puis @adonisjs/mail.use(MAIL_DRIVER) → Mailpit en dev, Resend en prod. Texte brut V1.
Triggers branchés :
- InvoicesController.store : si planId, scheduleRelancesForInvoice après création
- ImportBatchesController.validateDraft : pareil
- InvoicesController.markPaid : cancelFutureRelances dans la même tx que le paiement
#jobs/* ajouté aux imports package.json. Adonisrc preload start/queue.ts.
Bruno : doc 05-Invoices/04 Create maj avec instructions pour tester l'envoi immédiat (dueDate dans le passé → relance à now+1min → email visible dans Mailpit http://localhost:8025).
|