7 Commits

Author SHA1 Message Date
ordinarthur
919ebfe755 feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m18s
Première moitié de la feature marque blanche : la machinerie complète qui
permet à un compte Business d'envoyer ses emails de relance avec son
propre logo, ses propres couleurs et son nom comme expéditeur, à la place
du branding Rubis.

Architecture :

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:37:07 +02:00
ordinarthur
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).
2026-05-06 15:24:46 +02:00
ordinarthur
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).
2026-05-06 15:10:58 +02:00
ordinarthur
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).
2026-05-06 14:51:37 +02:00
ordinarthur
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.
2026-05-06 14:33:46 +02:00
ordinarthur
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).
2026-05-06 14:25:06 +02:00
ordinarthur
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>
2026-05-06 10:10:48 +02:00