9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
05cfa598b2 |
chore(bruno): mises à jour test data dans la collection
- 00-Auth/02 Login : trailing whitespace - 03-Clients/04 Create : test data variant (Martin2, siret/phone différents) pour éviter le conflit unique sur les exécutions successives - 05-Invoices/04 Create : numéro F-2026-0047, dueDate au 2026-04-01 (passée → déclenche l'envoi immédiat des RelanceTasks BullMQ pour tester le pipeline outbound) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3bad1451a9 |
docs(bruno): collection Billing + endpoints check-in in-app
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s
Nouveau dossier `09-Billing/` avec :
- folder.bru (overview : plans + flows upgrade/cancel/reactivate)
- 01 Get subscription : state du plan, caps, grace period, cancel flag
- 02 Start checkout : crée une Checkout Session Stripe (Pro/Business
× monthly/yearly)
- 03 Open portal : Customer Portal pour gérer CB/annulation
- 04 Reactivate : annule l'annulation programmée (sans paiement
immédiat) — gère le conflit Stripe
cancel_at vs cancel_at_period_end
Aussi documenté les endpoints in-app check-in qui manquaient dans Bruno :
- 03 In-app pending : liste des factures awaiting_user_confirmation
- 04 In-app respond paid : équivalent du lien email "C'est payé"
- 05 In-app respond pending : équivalent "Toujours en attente"
README mis à jour avec le parcours étendu (signup → … → billing).
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).
|
||
|
|
19dd71bd93 |
feat(api): MistralOcrProvider + multipart upload sur /invoices/upload
MistralOcrProvider (app/services/ocr/mistral_ocr_provider.ts) :
- Pipeline 2 étapes : POST /v1/ocr (mistral-ocr-latest) → markdown structuré, puis POST /v1/chat/completions (mistral-large-latest) avec response_format json_schema strict pour extraire les champs typés (clientName/Email, numero, amountTtcCents, issueDate, dueDate) + un objet `_conf` pour la confiance par champ.
- Télécharge le PDF depuis Drive (MinIO en dev) via getArrayBuffer, encode en base64 pour le data URI.
- Throw clair si storageKey null (incompatible avec le mode JSON {filenames}).
- Throw au constructor si MISTRAL_API_KEY manquante.
getOcrProvider() retourne maintenant vraiment Mistral quand OCR_PROVIDER=mistral (plus de fallback silencieux sur mock).
Multipart upload sur POST /invoices/upload :
- Détecte Content-Type. Si multipart/form-data : itère sur `files[]`, valide ext (pdf/png/jpg/jpeg) + size (10mb), upload chaque fichier vers `import-drafts/<orgId>/<draftId>.<ext>` via @adonisjs/drive, puis appelle createImportBatch avec sources [{filename, storageKey}].
- Si JSON : route compat conservée pour le mode démo.
Refactor service import_batch :
- Nouvelle fonction createImportBatch(orgId, sources) générique
- createImportBatchFromFilenames() devient un wrapper compat (storageKey null)
- OCR exécuté HORS transaction (calls réseau Mistral lents — 3-8s par PDF — pas de raison de tenir un lock PG)
Bruno :
- 06-Imports/02 Upload (multipart Mistral).bru — nouveau, body multipart-form avec @file() à sélectionner. Doc : setup .env, where to find files in MinIO console, latence Mistral.
- Renumérote 03/04/05/06 (Get batch / Validate / Skip / Cancel).
- Met à jour 01 Upload (mock) doc pour pointer vers 02 pour le vrai OCR.
Pour tester :
1. .env → OCR_PROVIDER=mistral + MISTRAL_API_KEY=...
2. Restart pnpm dev:api
3. Bruno → Imports → 02 Upload (multipart Mistral) → sélectionne un PDF
4. Bruno → Imports → 03 Get batch (drafts ont pdfStorageKey + extracted depuis l'OCR)
|
||
|
|
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).
|
||
|
|
27cfa9ac13 |
docs(bruno): collection complète des routes API + environnement local
Collection Bruno (.bru text files, comme Postman mais file-based versionable) qui couvre l'API V1 actuelle. Open Collection → bruno/ → sélectionner l'environnement "local". Domaines couverts (22 requêtes) : - 00-Auth : Signup, Login, Logout - 01-Account : Get/Update profile - 02-Organizations : Get/Update my org - 03-Clients : List, List+stats, Search, Create, Create duplicate (409), Create without email (422), Get detail, Update - 04-Plans : List, Get by slug, Update (steps remplacés) - 05-Invoices : List, List+filters, Counts, Create, Get detail, Mark paid Environnement local (bruno/environments/local.bru) : - baseUrl, email/password/fullName en dur - token, userId, organizationId, clientId, invoiceId remplis automatiquement par les script:post-response Chaque requête a : - assertions Chai (statut, shape de la réponse) - bloc docs avec sémantique métier + erreurs typiques - inheritance auth Bearer via folder.bru pour ne pas répéter le header Mise à jour de docs/tech/dev-setup.md pour pointer vers la collection. Le parcours recommandé Signup → Update org → Create client → Create invoice → Mark paid couvre le happy path et permet de checker rubisCount qui s'incrémente. |