36 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b81bc2609b |
fix(parametres): teaser banking l'emporte sur upsell + section démo admin-only
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>
|
||
|
|
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> |
||
|
|
3207f873e9 |
feat(banking): mode "Bientôt disponible" pendant la fenêtre KYC Powens
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>
|
||
|
|
51217175ad |
feat(banking): intégration Powens AISP + auto-réconciliation factures
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>
|
||
|
|
919ebfe755 |
feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
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> |
||
|
|
6dcae6956c |
feat(blog): admin CRUD + image upload + sidebar link
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>
|
||
|
|
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> |
||
|
|
e5530930b3 |
feat: refactor frontend en stack React unifiée (Astro + packages/ui)
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>
|
||
|
|
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>
|
||
|
|
f33b2dd319 |
feat(observability): Sentry monitoring API + Web (ADR-024)
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>
|
||
|
|
ff8fe64be2 |
feat(mail): templates HTML React Email + brand "Rubis sur l'ongle"
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>
|
||
|
|
cb87bbc8d1 |
feat(billing): expose l'annulation programmée + bouton "Réactiver"
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>
|
||
|
|
1952265217 |
feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
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>
|
||
|
|
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> |
||
|
|
1633fb9bf0 | add factories | ||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
ea539cd1d4 |
feat(auth): Google SSO via @adonisjs/ally
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>
|
||
|
|
dd93249362 |
refactor(deploy): split monolithique en 2 services (rubis-web nginx + rubis-api Node)
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>
|
||
|
|
461ab9bcd9 |
feat(deploy): app.rubis.arthurbarre.fr — image, manifests K3s, route Traefik
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> |
||
|
|
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>
|
||
|
|
cfa302ce9a |
fix(api): boot tolérant à Redis injoignable
Si Redis n'est pas dispo au démarrage, registerWorker peut throw lors de l'instantiation BullMQ. On wrap dans try/catch et on log un warning — l'API HTTP démarre quand même, ce qui permet de tester l'app en dev même quand docker-compose n'est pas up. Les jobs ne tourneront pas tant que Redis n'est pas joignable + serveur restart. |
||
|
|
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).
|
||
|
|
704f472729 |
feat(api): dashboard kpis + activity feed + top-late + ActivityEvent
Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).
Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.
Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.
Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.
Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5
Routes /api/v1/dashboard/* (auth requise).
Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.
Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
|
||
|
|
5d3408fafa |
feat(api): refresh tokens custom (cookie httpOnly + rotation panic-mode)
Pattern hybride (cf. backend.md §7) : access token Bearer 30min en JSON + refresh token 30j en cookie httpOnly `rubis_refresh` géré custom au-dessus d'@adonisjs/auth qui ne ship pas de primitive refresh.
Migration refresh_tokens (uuid id, user_id FK CASCADE, hashed_token unique [SHA-256, 64 chars hex], expires_at, last_used_at nullable, revoked_at nullable, ip_address, user_agent). Index user_id + expires_at.
Service refresh_token.ts :
- issueRefreshToken(user, ctx) : génère 32 bytes random → base64url → hash SHA-256 stocké, plain dans le cookie httpOnly + secure (en prod) + sameSite strict + path=/api/v1/auth (le browser n'envoie le cookie que sur les routes auth, pas chaque requête API).
- consumeRefreshToken(ctx) : lookup par hash, validation expiry/revoked. Si on présente un token DÉJÀ révoqué, panic mode : tous les refresh tokens actifs du user sont invalidés (signal de vol — le vrai propriétaire devra se re-logger).
- revokeCurrentRefreshToken / revokeAllForUser pour logout et le panic.
Service auth_session.ts : factorise emitAuthSession(user, ctx) qui crée access + refresh + retourne l'AuthSession. Utilisé par signup / login / refresh — DRY.
Controllers :
- POST /auth/signup : emitAuthSession après tx (org + plans + user).
- POST /auth/login : emitAuthSession après verifyCredentials.
- POST /auth/refresh (nouveau) : consumeRefreshToken → emitAuthSession. Rotation : l'ancien token devient révoqué, le nouveau est posé. SPA-side : appelé au boot pour rehydrater + après 401 silencieux.
- POST /account/logout : User.accessTokens.delete + revokeCurrentRefreshToken + clearCookie.
CORS a déjà credentials: true → le cookie traverse cross-origin si origin allowed.
Bruno : nouvelle requête `Auth/04 Refresh.bru` + folder doc + flow décrit dans README. Bruno honore la cookie jar nativement, donc aucun setup additionnel pour tester.
⚠️ Le contrôleur Refresh est nouveau → le registre Tuyau-généré .adonisjs/server/controllers.ts sera régénéré au prochain `pnpm dev:api` (la regen est un effet de bord du boot Adonis, on ne peut pas la déclencher seule). Avant ce premier boot, `pnpm typecheck` échouera sur l'absence de `controllers.Refresh` dans le registre.
|
||
|
|
c7714e3e8a |
feat(api): import OCR (batch + drafts) avec MockOcrProvider
Migrations :
- import_batches (uuid id, organization_id FK CASCADE)
- import_drafts (uuid id, batch_id FK CASCADE, filename, pdf_storage_key nullable, extracted/edited/confidence en jsonb, status ENUM PG natif pending/validated/skipped, invoice_id FK SET NULL)
Schema rules : tape précisément extracted/edited/confidence (sinon `any`) + status enum.
Services :
- OcrProvider : interface (storageKey + filename → champs avec confiance par champ)
- MockOcrProvider : génère des champs plausibles depuis le filename (numero parsed via regex, montants random multiples de 50cts, dates ISO décalées) + 30 % de cas avec emails à confiance basse pour simuler la review UX
- getOcrProvider() : sélectionne via OCR_PROVIDER env var (default mock, mistral en attente d'ADR-020)
- createImportBatchFromFilenames : compose extracted/edited/confidence par draft, tente un match client immédiat (case-insensitive sur le nom) pour pré-remplir clientId
- resolveClient extrait dans un service partagé (3 priorités : clientId → match nom → création + email requis), réutilisé par invoices_controller et import_batches_controller
Endpoints (auth + scope par organization) :
- POST /invoices/upload : V1 mock body { filenames[] }, 201 → ImportBatch avec ses drafts. Multipart upload réel quand Mistral arrivera, contrat de réponse identique.
- GET /invoices/import-batch/:id : poll pendant la review
- POST /invoices/import-batch/:id/drafts/:draftId/validate : crée Invoice (résolution client) + draft.status=validated + draft.invoiceId
- POST .../drafts/:draftId/skip : draft.status=skipped (idempotent)
- DELETE /invoices/import-batch/:id : CASCADE drop drafts, les invoices validées restent
Routes : ordre soigné — /upload, /counts, /import-batch/* AVANT /:id pour éviter le shadowing.
Bruno : nouveau dossier 06-Imports avec 5 requêtes documentées + capture batchId/draftId dans l'env local. README mis à jour avec le parcours étendu (étapes 11-13).
|
||
|
|
005af557c2 |
feat(api): domaine Invoice + endpoints CRUD + branche stats Client/Plan
Migration invoices : uuid id, organization_id FK CASCADE, client_id FK RESTRICT (on n'efface pas les factures si l'utilisateur supprime un client par erreur — audit/comptable), plan_id FK SET NULL, numero, amount_ttc_cents (int, jamais float), issue_date, due_date, status ENUM PG natif (pending/awaiting_user_confirmation/in_relance/paid/litigation/cancelled), pdf_storage_key, notes, rubis_earned, paid_at. Indexes (org,status), (org,client_id), (org,due_date), unique (org,numero). Modèles : Invoice avec belongsTo Organization/Client/Plan. Client et Plan étendus avec hasMany Invoice maintenant que la table existe. Endpoints : - GET /invoices : filtres status/q/clientId/page, tri actionnable (awaiting_user_confirmation puis in_relance puis pending puis litigation puis paid puis cancelled), pagination simple 50/page (cursor-based en V2). - GET /invoices/counts : compteurs par statut pour les chips dashboard, requête agrégée groupBy. - GET /invoices/:id : détail enrichi avec client + plan préchargés + timeline composée par buildTimeline() (étapes du plan calées sur due_date, états past/current/future). - POST /invoices : saisie manuelle. Résolution client en 3 étapes (clientId → match par nom → création à la volée avec email REQUIS, sinon 422 client_email_required). Bonus +1 rubis à la création. - POST /invoices/:id/mark-paid : status=paid + paid_at + bonus +1 rubis (sur invoice + sur organization.rubis_count). Idempotent. L'ordre des routes /invoices/counts AVANT /invoices/:id est critique sinon `:id` matche "counts". Branche les vraies stats : - ClientStats : agrégation PG une seule requête (count, count actives, count en retard, paid_count, sum paid_cents, sum pending_cents, last_activity) avec FILTER clauses et casting enum::text. Plus de TODO/zéros. - PlansController : usageCount calculé pareil (factures actives référençant le plan). Skip pour l'instant (ImportBatch domain à venir) : POST /invoices/upload, GET /invoices/import-batch/*, validate/skip drafts. |
||
|
|
692b514fe9 |
feat(api): domaine Plan + PlanStep + provisioning des 4 plans pré-fournis
Migrations : - plans (uuid id, organization_id FK CASCADE, slug nullable, name, description, is_default). Unique (organization_id, slug) — un slug max par org. - plan_steps (uuid id, plan_id FK CASCADE, order, offset_days, tone ENUM PG natif, subject, body, requires_manual_validation). Schema rules : override du tone (introspection PG → 'any', on précise l'union). Modèles Plan (belongsTo Organization, hasMany PlanStep) et PlanStep (belongsTo Plan). Décision : plans dupliqués par organisation au signup (pas de table globale partagée). Permet l'édition isolée par org sans toucher aux templates des autres tenants. Le service `provisionDefaultPlans(orgId, trx)` est idempotent et appelé depuis NewAccountController dans la transaction de création. Source de vérité des 4 plans (Standard B2B, Rapide, Patient, Ferme) dans app/services/default_plans.ts — alignée sur apps/web/src/mocks/seed.ts. Endpoints : - GET /plans : liste enrichie avec usageCount (à 0 tant qu'Invoice n'est pas câblé). - GET /plans/:slug : détail (lookup par slug pour URL stable côté SPA). - PATCH /plans/:slug : édition partielle. Les steps sont remplacés en bloc dans une transaction (pas de diff fin id-par-id, plus simple et prévisible). POST plan custom = V2 (cf. backend.md §5.5). |
||
|
|
b6006ad1f7 |
feat(api): domaine Client + CRUD /api/v1/clients
Migration clients (uuid id, organization_id FK uuid CASCADE, name, email REQUIS, phone, address, siret, notes). Index sur organization_id. Modèle Client avec belongsTo Organization. La relation hasMany Invoice est volontairement omise tant que le domaine Invoice n'est pas câblé. Validators Vine alignés sur le contrat MSW : - create : name 2-120, email requis avec format, siret 14 chiffres si fourni - update : tout optionnel - email REQUIS au create — pivot produit, pas de relance possible sans Endpoints (auth requise, scopés par organizationId du user courant) : - GET /clients?withStats=1&q= : liste filtrée + recherche, enrichissement stats optionnel, tri par actionnabilité (retards d'abord) quand withStats - GET /clients/:id : détail (id en UUID via router.matchers.uuid()) - POST /clients : 201 + détection doublon par nom case-insensitive → 409 avec payload `existing` (le SPA peut proposer "voir le client existant") - PATCH /clients/:id : merge partiel Service ClientStats avec interface bulkComputeClientStats() qui retourne EMPTY pour l'instant — sera vraiment branché quand Invoice arrive. Le contrat reste stable côté SPA, juste les compteurs à 0. Sérialisation : pour les listes avec stats per-item, on instancie le transformer manuellement (`new ClientTransformer(c).toObject()`) plutôt que de passer par BaseTransformer.transform() qui retourne un Item nested non-unwrappable hors clé directe de serialize(). |
||
|
|
eeb4ce25b8 |
feat(api): domaine Organization + endpoints /organizations/me
- Migrations 'organizations' (id, name, siret, monthly_volume_bucket, rubis_count, onboarding_completed_at) + alter users (organization_id FK + signature).
- Modèle Organization avec relation hasMany Users, User étendu avec belongsTo Organization.
- Signup transactionnel : crée une org vide ('') puis l'user, puis émet le access token. Le nom de l'org reste vide tant que l'utilisateur n'a pas franchi la première étape de l'onboarding (PATCH /organizations/me).
- Réponses /auth/* alignées sur le contrat SPA AuthSession : { data: { accessToken, expiresAt, user } }. Drop passwordConfirmation (le SPA n'envoie pas ce champ).
- Endpoints :
- GET /account/profile (déjà), PATCH /account/profile (nouveau, fullName/email/signature).
- GET /organizations/me + PATCH /organizations/me (name/siret/monthlyVolumeBucket).
- Pose automatique d'onboardingCompletedAt à la première mise en place du nom de l'org — remplace l'astuce 'signature !== null' utilisée côté MSW.
- Transformers convertissent les IDs en string (pour matcher packages/shared/src/types).
- HMR boundaries élargies : transformers/validators/services se rechargent maintenant à chaud (sinon les modifs ne sont pas vues sans restart manuel).
|
||
|
|
274f2a8270 |
feat(api): install + configure bouncer, mail, limiter, drive, bullmq
Stack backend complète selon docs/tech/backend.md §2 : - @adonisjs/bouncer : configure standard, middleware initialize_bouncer simplifié (API JSON-only, pas d'Edge views). - @adonisjs/limiter : store Redis par défaut, throttler global défini dans start/limiter.ts. - @adonisjs/mail : transports SMTP (Mailpit en dev) + Resend (prod). - @adonisjs/drive : services fs (fallback) + S3 (MinIO en dev, prod plus tard). - bullmq + ioredis : config queue.ts définit la connection Redis et la liste des queues (ocr, relances, checkins, kpis). Worker à câbler dans le commit suivant. - @aws-sdk/client-s3 + s3-request-presigner pour le driver flydrive S3. Pas de @rlanz/bull-queue : peer Adonis 6.5, plus maintenu — on consomme BullMQ directement. |
||
|
|
4a6c778e7c |
chore(api): docker-compose dev (PG/Redis/MinIO/Mailhog) + bascule sur Postgres
- docker-compose.dev.yml à la racine : PG 16, Redis 7, MinIO + bucket auto, Mailhog. Ports décalés (5433, 6380, 9100…) pour éviter les collisions locales. - apps/api/config/database.ts : Postgres en default, SQLite reste accessible via DB_CONNECTION=sqlite. - start/env.ts : validation des nouvelles vars (PG, Redis, S3, Mail, OCR, refresh tokens). - .env.example complété, scripts pnpm dev:up/down/logs/reset à la racine. - docs/tech/dev-setup.md pour expliquer la stack locale. |
||
|
|
8d3bab6a89 |
feat: scaffold frontend monorepo + first /login screen
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>
|