add ocr + add factures

This commit is contained in:
ordinarthur 2026-05-06 18:47:35 +02:00
parent c4486d9e5e
commit 5e41e2a9fa
16 changed files with 566 additions and 223 deletions

View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(pnpm -F api typecheck)",
"Bash(pnpm -F @rubis/web typecheck)"
]
}
}

169
AGENTS.md Normal file
View File

@ -0,0 +1,169 @@
# Rubis Sur l'Ongle
> **Le SaaS de relance de factures impayées pour TPE-PME françaises.** Drag-and-drop, OCR, plans de relance automatiques. 1 rubis = 10 minutes libérées.
Ce fichier est le contexte top-level. Il est court, dense, scannable. Pour les détails, voir `/docs/`.
---
## En une phrase
Vos factures se relancent toutes seules pendant que vous travaillez.
## Cible
TPE-PME françaises, 5 à 50 salariés, qui émettent 10 à 200 factures par mois, sans crédit manager dédié. Le décideur teste lui-même le produit (pas de cycle de vente long).
## Promesse de valeur
- **5 heures par semaine récupérées** (benchmark : 8h → <3h après automatisation).
- **Tonalité émotionnelle** : on vend du temps libéré, pas de la trésorerie. Le rubis gagné est la métrique-héros, pas le DSO.
- **2 à 3 clics maximum** pour lancer une relance sur une nouvelle facture.
## Principes produit (toujours valides)
1. **3 clics maximum** pour lancer une relance sur une facture neuve. Idéalement 2 si bien configuré.
2. **Mobile et desktop** — la photo de facture depuis le téléphone est un usage clé.
3. **Pure-player relance** — on ne fait pas CRM, pas facturation, pas comptabilité. On fait une chose et on la fait bien.
4. **Respectueux du client final** — le ton monte avec le retard, jamais avant. Pas d'agressivité par défaut.
5. **Le rubis est une vraie devise produit** — 1 rubis = 10 min libérées. La gamification doit être tangible et défendable.
## Identité de marque (TLDR)
| | |
|---|---|
| **Logo** | Direction A — gem facetté géométrique. Le ◆ est un symbole produit autant qu'un logo. |
| **Couleur primaire** | `#9F1239` — rubis profond légèrement violacé. *Anti-Coca-Cola.* |
| **Couleur secondaires** | `#771328` (deep), `#C9415C` (light), `#FBE4EA` (glow) |
| **Neutres** | Crème `#FAF7F2`, encre chaude `#1A1410`. Jamais de blanc pur, jamais de noir pur. |
| **Typo display** | Bricolage Grotesque (500800), Google Fonts |
| **Typo body** | Inter (400700), Google Fonts |
| **Icônes** | Lucide (regular weight) |
| **Pas de** | or, bleu, vert, violet, emojis joaillerie 💎💰, mot "recouvrement" en com publique |
Voir `/docs/marque.md` pour la référence complète et `/brand-identity.html` pour la présentation visuelle (note : la mention de l'or accent dans ce fichier est obsolète, à ignorer).
## Voix
Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associé, pas comme une DAF.*
- ✓ "Vos factures relancées toutes seules."
- ✗ "Optimisez votre processus de recouvrement amiable."
## Glossaire
- **Rubis** : unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance qu'on n'a pas eu à faire à la main.
- **Plan de relance** : cadence d'emails automatisés (ex. J+3, J+10, J+20). Chaque facture est associée à un plan.
- **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme").
- **Check-in** : email envoyé **à l'utilisateur** (pas au client) pour confirmer si une facture a été payée avant l'envoi de la prochaine relance. Remplace l'intégration banking en V1.
- **Mise en demeure** : étape ferme du plan. **Toujours sous validation manuelle** via modale de confirmation, jamais auto.
- **DSO** : Days Sales Outstanding. Métrique secondaire dans l'app, jamais dans la com publique.
- **LME** : loi de modernisation de l'économie (2008). Plafonne les délais de paiement à 60 jours (ou 45 jours fin de mois). Sanctions DGCCRF jusqu'à 2 M€.
## Périmètre V1
### IN
- Auth email/password + Google SSO
- Onboarding 3 étapes (compte, entreprise, signature email)
- Upload drag-and-drop + OCR factures (PDF, PNG, JPG)
- Saisie manuelle (fallback)
- Bibliothèque de plans (4 plans fournis par défaut)
- Éditeur de plan (cadence + templates email avec variables)
- Check-in email à l'utilisateur (cadence configurable) → confirme si payé → relance ou stop
- Dashboard avec compteur rubis + KPIs (à relancer, encaissé, DSO)
- Liste filtrable des factures
- Détail facture avec timeline des relances
- App mobile (web responsive)
### OUT (V2 ou plus tard)
- **SMS** — uniquement plan le plus cher en V2
- **Multi-utilisateurs** — uniquement plans payants en V2
- **Intégration banking / réconciliation auto** — l'architecture V1 doit l'anticiper, mais l'implémentation est V2+
- Multi-langues, multi-devises (FR/EUR only en V1)
- Intégration ERP/comptable (Sage, Pennylane, Quickbooks)
## Pricing (esquisse, à valider)
| Plan | Prix | Limite |
|---|---|---|
| **Free** | 0 € | 5 factures actives en relance, 1 utilisateur |
| **Pro** | 19 €/mois | Factures illimitées, OCR illimité, 1 utilisateur |
| **Business** | 49 €/mois | + multi-utilisateurs, + branding email, + SMS (V2) |
Argument de vente : *"moins cher qu'une heure de votre temps mensuel"*.
## Décisions clés validées (résumé)
Voir `/docs/decisions.md` pour le log complet avec rationale.
- 1 rubis = 10 minutes libérées
- Logo direction A (gem facetté), wordmark à monter en parallèle plus tard
- Palette rubis chaude, sans or, sans bleu
- Typo Bricolage Grotesque + Inter
- Iconographie Lucide
- Mise en demeure : validation manuelle obligatoire (modale)
- SMS et multi-users : V2 + plans payants seulement
- Banking intégration : pas en V1, remplacée par check-in emails
## Stack technique
| Couche | Choix | Source |
|---|---|---|
| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
| Frontend | **React + Vite** | ADR-014 |
| Routing client | **TanStack Router** | ADR-014 |
| State serveur | **TanStack Query** | ADR-014 |
| Base de données | **PostgreSQL** | ADR-014 |
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
| OCR provider | à benchmarker | ADR-020 (en attente) |
| Email outbound | à benchmarker | ADR-021 (en attente) |
**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
### Conventions techniques (cross-cutting)
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire.
## Documents associés
| Fichier | Rôle |
|---|---|
| `/AGENTS.md` (ce fichier) | Contexte top-level, toujours en tête |
| `/landing/index.html` | Landing page brand-applied, déployée (waitlist V1) |
| `/landing/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
| `/landing/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
| `/landing/assets/logo.png` | Logo Rubis original (généré, source pour les favicons) |
| `/docs/produit.md` | Spec produit détaillée (features, flows, IN/OUT V1) |
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) |
| `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) |
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
| `/.Codex/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |
## Déploiement
- **Image** : `git.arthurbarre.fr/ordinarthur/rubis:latest`
- **Domaine actuel** (temporaire) : https://rubis.arthurbarre.fr
- **Build** : `COPY landing/` → nginx servi sur port 80
- Voir `.Codex/deploy-memory.md` pour la procédure complète.
## Questions ouvertes
- **Stack technique app produit** à formaliser (la landing tourne en static nginx, mais le SaaS lui-même reste à scoper)
- **Conversion 1 rubis = 10 min** validée mais à confirmer en user testing après MVP
- **Wordmark "rubis" avec gem-i** (direction C) à monter en complément du logo A à un moment
- **Provider OCR** à benchmarker (Mindee, Document AI, Textract, Tesseract)
- **Endpoint waitlist** à câbler dans `/landing/index.html` (Resend, Formspree, ou API perso)
- **Domaine définitif** à acheter (le sous-domaine actuel est temporaire)
---
*Dernière mise à jour : 2026-05-05 · Maintenu par Arthur + Codex.*

View File

@ -61,7 +61,7 @@ RESEND_API_KEY=
#-------------------------------------------------------------------- #--------------------------------------------------------------------
# OCR (Mistral) # OCR (Mistral)
#-------------------------------------------------------------------- #--------------------------------------------------------------------
OCR_PROVIDER=mock OCR_PROVIDER=mistral
MISTRAL_API_KEY= MISTRAL_API_KEY=
#-------------------------------------------------------------------- #--------------------------------------------------------------------
@ -76,4 +76,4 @@ ACCESS_TOKEN_TTL_MINUTES=30
REFRESH_TOKEN_TTL_DAYS=30 REFRESH_TOKEN_TTL_DAYS=30
COOKIE_DOMAIN= COOKIE_DOMAIN=
COOKIE_SECURE=false COOKIE_SECURE=false
LIMITER_STORE=redis LIMITER_STORE=redis

View File

@ -2,7 +2,7 @@ import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice' import Invoice from '#models/invoice'
import { hashCheckinToken } from '#services/checkin_token' import { hashCheckinToken } from '#services/checkin_token'
import { recordActivity } from '#services/activity_recorder' import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler' import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import env from '#start/env' import env from '#start/env'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
@ -24,9 +24,7 @@ function spaRedirectUrl(
return `${base}/?${params.toString()}` return `${base}/?${params.toString()}`
} }
type ResolvedTask = type ResolvedTask = { task: CheckinTask; invoice: Invoice } | { redirect: string }
| { task: CheckinTask; invoice: Invoice }
| { redirect: string }
/** /**
* Lookup + validation commune aux deux endpoints (paid / pending). * Lookup + validation commune aux deux endpoints (paid / pending).
@ -52,10 +50,7 @@ async function resolveCheckin(token: string): Promise<ResolvedTask> {
return { redirect: spaRedirectUrl('expired') } return { redirect: spaRedirectUrl('expired') }
} }
const invoice = await Invoice.query() const invoice = await Invoice.query().where('id', task.invoiceId).preload('client').first()
.where('id', task.invoiceId)
.preload('client')
.first()
if (!invoice) { if (!invoice) {
return { redirect: spaRedirectUrl('invalid') } return { redirect: spaRedirectUrl('invalid') }
} }
@ -119,7 +114,7 @@ export default class CheckinController {
* GET /api/v1/checkin/:token/pending * GET /api/v1/checkin/:token/pending
* *
* L'utilisateur clique "toujours en attente". On marque la task * L'utilisateur clique "toujours en attente". On marque la task
* answered, les relances suivent leur cours. * answered, puis on programme les relances client.
*/ */
async respondPending({ params, response }: HttpContext) { async respondPending({ params, response }: HttpContext) {
const result = await resolveCheckin(params.token) const result = await resolveCheckin(params.token)
@ -133,6 +128,10 @@ export default class CheckinController {
task.answeredAt = DateTime.now() task.answeredAt = DateTime.now()
await task.save() await task.save()
if (invoice.planId) {
await scheduleRelancesForInvoice(invoice)
}
return response.redirect(spaRedirectUrl('pending', invoice.numero)) return response.redirect(spaRedirectUrl('pending', invoice.numero))
} }
} }

View File

@ -1,14 +1,9 @@
import ImportBatch from '#models/import_batch' import ImportBatch from '#models/import_batch'
import Invoice from '#models/invoice' import Invoice from '#models/invoice'
import Plan from '#models/plan' import Plan from '#models/plan'
import ImportBatchTransformer, { import ImportBatchTransformer, { serializeDraft } from '#transformers/import_batch_transformer'
serializeDraft,
} from '#transformers/import_batch_transformer'
import InvoiceTransformer from '#transformers/invoice_transformer' import InvoiceTransformer from '#transformers/invoice_transformer'
import { import { uploadValidator, validateDraftValidator } from '#validators/import_batch'
uploadValidator,
validateDraftValidator,
} from '#validators/import_batch'
import { resolveClient } from '#services/resolve_client' import { resolveClient } from '#services/resolve_client'
import { import {
createImportBatch, createImportBatch,
@ -16,7 +11,6 @@ import {
type ImportSource, type ImportSource,
} from '#services/import_batch' } from '#services/import_batch'
import { recordActivity } from '#services/activity_recorder' import { recordActivity } from '#services/activity_recorder'
import { scheduleRelancesForInvoice } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler' import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
import drive from '@adonisjs/drive/services/main' import drive from '@adonisjs/drive/services/main'
@ -225,12 +219,9 @@ export default class ImportBatchesController {
await invoice.load('plan') await invoice.load('plan')
try { try {
if (invoice.planId) {
await scheduleRelancesForInvoice(invoice)
}
await scheduleCheckinForInvoice(invoice) await scheduleCheckinForInvoice(invoice)
} catch (err) { } catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin') logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
} }
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() }) return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })

View File

@ -1,24 +1,16 @@
import Invoice from '#models/invoice' import Invoice from '#models/invoice'
import Plan from '#models/plan' import Plan from '#models/plan'
import RelanceTask from '#models/relance_task'
import InvoiceTransformer from '#transformers/invoice_transformer' import InvoiceTransformer from '#transformers/invoice_transformer'
import { import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice'
createInvoiceValidator,
listInvoicesValidator,
} from '#validators/invoice'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions' import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client' import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder' import { recordActivity } from '#services/activity_recorder'
import { import { cancelFutureRelances } from '#services/relance_scheduler'
scheduleRelancesForInvoice, import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
cancelFutureRelances,
} from '#services/relance_scheduler'
import {
scheduleCheckinForInvoice,
cancelCheckinForInvoice,
} from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -53,7 +45,10 @@ function serializeInvoice(i: Invoice) {
* - étape actuelle (la prochaine future) : 'current' * - étape actuelle (la prochaine future) : 'current'
* - étapes futures : 'future' * - étapes futures : 'future'
*/ */
function buildTimeline(invoice: Invoice): Array<{ function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = []
): Array<{
id: string id: string
state: 'past' | 'current' | 'future' state: 'past' | 'current' | 'future'
when: string when: string
@ -73,35 +68,43 @@ function buildTimeline(invoice: Invoice): Array<{
}, },
] ]
if ( if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
invoice.plan?.steps?.length &&
invoice.status !== 'paid' &&
invoice.status !== 'cancelled'
) {
const dueMs = invoice.dueDate.toMillis() const dueMs = invoice.dueDate.toMillis()
const nowMs = DateTime.now().toMillis() const nowMs = DateTime.now().toMillis()
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
let currentSet = false let currentSet = false
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) { for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000 const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
const stepDate = DateTime.fromMillis(sendMs) const task = taskByStepId.get(step.id)
const stepDate = task?.sentAt ?? task?.sendAt ?? DateTime.fromMillis(sendMs)
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}` const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
let state: 'past' | 'current' | 'future' let state: 'past' | 'current' | 'future'
if (sendMs < nowMs) state = 'past' if (task?.status === 'sent') state = 'past'
else if (!currentSet) { else if (task?.status === 'scheduled' && task.sendAt.toMillis() < nowMs) state = 'current'
else if (!task && invoice.status === 'pending' && !currentSet) {
state = 'current'
currentSet = true
} else if (!currentSet) {
state = 'current' state = 'current'
currentSet = true currentSet = true
} else state = 'future' } else state = 'future'
const subject = step.subject.replace('{{numero}}', invoice.numero)
const what = task
? task.status === 'sent'
? `Email envoyé · "${subject}"`
: `Email programmé · "${subject}"`
: invoice.status === 'pending'
? `À programmer après check-in · "${subject}"`
: `Relance non programmée · "${subject}"`
events.push({ events.push({
id: `${invoice.id}__step_${step.order}`, id: `${invoice.id}__step_${step.order}`,
state, state,
when: `${formatShortDate(stepDate)} · ${labelStep}`, when: `${formatShortDate(stepDate)} · ${labelStep}`,
what: what,
state === 'past'
? `Email envoyé · "${step.subject.replace('{{numero}}', invoice.numero)}"`
: `Email programmé · "${step.subject.replace('{{numero}}', invoice.numero)}"`,
}) })
} }
} }
@ -223,6 +226,9 @@ export default class InvoicesController {
} }
const data = serializeInvoice(invoice) const data = serializeInvoice(invoice)
const relanceTasks = await RelanceTask.query()
.where('invoice_id', invoice.id)
.whereNot('status', 'cancelled')
return response.json({ return response.json({
data: { data: {
...data, ...data,
@ -251,7 +257,7 @@ export default class InvoicesController {
requiresManualValidation: s.requiresManualValidation, requiresManualValidation: s.requiresManualValidation,
})), })),
}, },
timeline: buildTimeline(invoice), timeline: buildTimeline(invoice, relanceTasks),
}, },
}) })
} }
@ -305,18 +311,12 @@ export default class InvoicesController {
await invoice.load('client') await invoice.load('client')
await invoice.load('plan') await invoice.load('plan')
// Programme les relances BullMQ si la facture a un plan + le check-in // Programme uniquement le check-in (envoyé à dueDate). Les relances
// (envoyé pile à dueDate). Hors tx — Redis ne participe pas aux // client ne partent qu'après confirmation "toujours en attente".
// garanties DB. On ne fait pas planter la requête HTTP si Redis est
// down : la facture est créée, l'utilisateur peut re-déclencher la
// programmation plus tard.
try { try {
if (invoice.planId) {
await scheduleRelancesForInvoice(invoice)
}
await scheduleCheckinForInvoice(invoice) await scheduleCheckinForInvoice(invoice)
} catch (err) { } catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin') logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
} }
return response.status(201).json({ data: serializeInvoice(invoice) }) return response.status(201).json({ data: serializeInvoice(invoice) })
@ -353,10 +353,7 @@ export default class InvoicesController {
await invoice.save() await invoice.save()
// Bump du compteur agrégé sur l'organisation // Bump du compteur agrégé sur l'organisation
await trx await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
.from('organizations')
.where('id', organizationId)
.increment('rubis_count', 1)
// Journal d'activité (cf. dashboard activity feed). // Journal d'activité (cf. dashboard activity feed).
await recordActivity({ await recordActivity({

View File

@ -8,6 +8,13 @@ const OCR_MODEL = 'mistral-ocr-latest'
// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict). // Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict).
const EXTRACTION_MODEL = 'mistral-large-latest' const EXTRACTION_MODEL = 'mistral-large-latest'
const MIME_BY_EXT: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
}
const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B. const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B.
Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un
JSON strict avec les champs demandés. JSON strict avec les champs demandés.
@ -51,14 +58,15 @@ export class MistralOcrProvider implements OcrProvider {
) )
} }
// 1. Télécharge le PDF depuis Drive (MinIO en dev) puis encode en base64. // 1. Télécharge le fichier depuis Drive (MinIO en dev) puis encode en base64.
const buffer = await this.downloadAsBuffer(input.storageKey) const buffer = await this.downloadAsBuffer(input.storageKey)
const dataUri = `data:application/pdf;base64,${buffer.toString('base64')}` const mimeType = this.mimeTypeFromFilename(input.filename)
const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`
// 2. OCR → markdown // 2. OCR → markdown
const ocrJson = await this.postJson('/ocr', { const ocrJson = await this.postJson('/ocr', {
model: OCR_MODEL, model: OCR_MODEL,
document: { type: 'document_url', document_url: dataUri }, document: this.documentPayload(dataUri, mimeType),
}) })
const markdown = (ocrJson?.pages ?? []) const markdown = (ocrJson?.pages ?? [])
.map((p: { markdown?: string }) => p.markdown ?? '') .map((p: { markdown?: string }) => p.markdown ?? '')
@ -93,6 +101,27 @@ export class MistralOcrProvider implements OcrProvider {
return Buffer.from(arr) return Buffer.from(arr)
} }
private mimeTypeFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const mimeType = MIME_BY_EXT[ext]
if (!mimeType) {
throw new Error(`Format OCR non supporté pour "${filename}"`)
}
return mimeType
}
private documentPayload(
dataUri: string,
mimeType: string
):
| { type: 'document_url'; document_url: string }
| { type: 'image_url'; image_url: string } {
if (mimeType === 'application/pdf') {
return { type: 'document_url', document_url: dataUri }
}
return { type: 'image_url', image_url: dataUri }
}
private async postJson(path: string, body: unknown): Promise<any> { private async postJson(path: string, body: unknown): Promise<any> {
const res = await fetch(`${MISTRAL_API}${path}`, { const res = await fetch(`${MISTRAL_API}${path}`, {
method: 'POST', method: 'POST',

View File

@ -1,7 +1,7 @@
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import RelanceTask from '#models/relance_task' import RelanceTask from '#models/relance_task'
import Plan from '#models/plan' import Plan from '#models/plan'
import Invoice from '#models/invoice' import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue' import { getQueue } from '#services/queue'
import app from '@adonisjs/core/services/app' import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
@ -25,10 +25,10 @@ function shouldEnqueue(): boolean {
* - Crée une RelanceTask `scheduled` * - Crée une RelanceTask `scheduled`
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now * - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
* *
* Si sendAt est dans le passé (cas : facture importée avec une dueDate * Si une facture est déjà en retard quand l'utilisateur confirme "toujours
* ancienne), on programme quand même la task pour `now + 1 min` l'user * en attente", on n'envoie pas toutes les étapes passées d'un coup :
* est probablement en train de "rattraper" un retard, l'envoi immédiat * la première étape éligible part à `now + 1 min`, puis les suivantes
* est cohérent. * gardent l'écart du plan à partir de ce nouveau départ.
* *
* Idempotent par invoice.id : si des tasks `scheduled` existent déjà * Idempotent par invoice.id : si des tasks `scheduled` existent déjà
* pour cette facture, on les annule avant de re-programmer (cas on * pour cette facture, on les annule avant de re-programmer (cas on
@ -58,17 +58,32 @@ export async function scheduleRelancesForInvoice(
// Ignore — le job peut déjà être consommé. // Ignore — le job peut déjà être consommé.
}) })
} }
t.useTransaction(trx ?? null as never) t.useTransaction(trx ?? (null as never))
t.status = 'cancelled' t.status = 'cancelled'
await t.save() await t.save()
} }
const now = DateTime.now() const now = DateTime.now()
const created: RelanceTask[] = [] const created: RelanceTask[] = []
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
const firstOverdueStep = steps.find(
(step) => invoice.dueDate.plus({ days: step.offsetDays }) < now
)
const catchUpAnchor = firstOverdueStep
? {
offsetDays: firstOverdueStep.offsetDays,
sendAt: now.plus({ minutes: 1 }),
}
: null
for (const step of plan.steps) { for (const step of steps) {
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays }) const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw const sendAt =
catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays
? catchUpAnchor.sendAt.plus({
days: step.offsetDays - catchUpAnchor.offsetDays,
})
: sendAtRaw
const task = await RelanceTask.create( const task = await RelanceTask.create(
{ {
@ -128,7 +143,7 @@ export async function cancelFutureRelances(
if (t.queueJobId && queue) { if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {}) await queue.remove(t.queueJobId).catch(() => {})
} }
t.useTransaction(trx ?? null as never) t.useTransaction(trx ?? (null as never))
t.status = 'cancelled' t.status = 'cancelled'
await t.save() await t.save()
} }

View File

@ -1,9 +1,13 @@
import { test } from '@japa/runner' import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils' import testUtils from '@adonisjs/core/services/test_utils'
import RelanceTask from '#models/relance_task' import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import Organization from '#models/organization' import Organization from '#models/organization'
import { hashCheckinToken } from '#services/checkin_token'
import { createTestUser, createTwoOrgs } from '../helpers/auth.js' import { createTestUser, createTwoOrgs } from '../helpers/auth.js'
import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js' import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js'
import { DateTime } from 'luxon'
type InvoiceShape = { type InvoiceShape = {
id: string id: string
@ -35,24 +39,18 @@ async function getStandardPlan(client: any, headers: Record<string, string>) {
test.group('Invoices — POST /invoices', (group) => { test.group('Invoices — POST /invoices', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction()) group.each.setup(() => testUtils.db().withGlobalTransaction())
test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({ test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({ client, assert }) => {
client,
assert,
}) => {
const { bearer } = await createTestUser() const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Boulangerie Spec') const c = await createClient(client, bearer, 'Boulangerie Spec')
const r = await client const r = await client.post('/api/v1/invoices').headers(bearer).json({
.post('/api/v1/invoices') clientId: c.id,
.headers(bearer) clientName: c.name,
.json({ numero: 'F-2026-T01',
clientId: c.id, amountTtcCents: 124000,
clientName: c.name, issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-T01', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 124000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(201) r.assertStatus(201)
const inv = body<ApiOk<InvoiceShape>>(r).data const inv = body<ApiOk<InvoiceShape>>(r).data
@ -60,23 +58,17 @@ test.group('Invoices — POST /invoices', (group) => {
assert.equal(inv.status, 'pending') assert.equal(inv.status, 'pending')
}) })
test('crée à la volée un client si nom non matché + email fourni', async ({ test('crée à la volée un client si nom non matché + email fourni', async ({ client, assert }) => {
client,
assert,
}) => {
const { bearer, accessToken } = await createTestUser() const { bearer, accessToken } = await createTestUser()
const r = await client const r = await client.post('/api/v1/invoices').headers(bearer).json({
.post('/api/v1/invoices') clientName: 'Nouveau Client',
.headers(bearer) clientEmail: 'nouveau@spec.test',
.json({ numero: 'F-2026-T02',
clientName: 'Nouveau Client', amountTtcCents: 50000,
clientEmail: 'nouveau@spec.test', issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-T02', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 50000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(201) r.assertStatus(201)
@ -95,46 +87,91 @@ test.group('Invoices — POST /invoices', (group) => {
}) => { }) => {
const { bearer } = await createTestUser() const { bearer } = await createTestUser()
const r = await client const r = await client.post('/api/v1/invoices').headers(bearer).json({
.post('/api/v1/invoices') clientName: 'Sans Email',
.headers(bearer) numero: 'F-2026-T03',
.json({ amountTtcCents: 50000,
clientName: 'Sans Email', issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-T03', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 50000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(422) r.assertStatus(422)
const errors = body<{ errors: Array<{ code: string }> }>(r).errors const errors = body<{ errors: Array<{ code: string }> }>(r).errors
assert.equal(errors[0].code, 'client_email_required') assert.equal(errors[0].code, 'client_email_required')
}) })
test('schedule des RelanceTasks si planId est fourni', async ({ client, assert }) => { test('schedule uniquement le check-in si planId est fourni', async ({ client, assert }) => {
const { bearer } = await createTestUser() const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Avec Plan') const c = await createClient(client, bearer, 'Avec Plan')
const plan = await getStandardPlan(client, bearer) const plan = await getStandardPlan(client, bearer)
const r = await client const r = await client.post('/api/v1/invoices').headers(bearer).json({
clientId: c.id,
clientName: c.name,
planId: plan.id,
numero: 'F-2026-T04',
amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(201)
const inv = body<ApiOk<InvoiceShape>>(r).data
const tasks = await RelanceTask.query().where('invoice_id', inv.id)
const checkins = await CheckinTask.query().where('invoice_id', inv.id)
assert.lengthOf(tasks, 0)
assert.lengthOf(checkins, 1)
assert.equal(checkins[0].status, 'scheduled')
})
test('clic check-in pending programme les relances sans rafale passée', async ({
client,
assert,
}) => {
const { bearer, org } = await createTestUser()
const c = await createClient(client, bearer, 'Checkin Pending')
const plan = await getStandardPlan(client, bearer)
const dueDate = DateTime.now().minus({ days: 20 }).set({ hour: 9, minute: 0, second: 0 })
const created = await client
.post('/api/v1/invoices') .post('/api/v1/invoices')
.headers(bearer) .headers(bearer)
.json({ .json({
clientId: c.id, clientId: c.id,
clientName: c.name, clientName: c.name,
planId: plan.id, planId: plan.id,
numero: 'F-2026-T04', numero: 'F-2026-CHECKIN',
amountTtcCents: 50000, amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z', issueDate: dueDate.minus({ days: 15 }).toISO(),
dueDate: '2026-05-01T09:00:00.000Z', dueDate: dueDate.toISO(),
}) })
r.assertStatus(201) created.assertStatus(201)
const inv = body<ApiOk<InvoiceShape>>(r).data const inv = body<ApiOk<InvoiceShape>>(created).data
const tasks = await RelanceTask.query().where('invoice_id', inv.id) const plain = 'pending-token-spec'
// Le plan standard-30j a 3 steps const checkin = await CheckinTask.query().where('invoice_id', inv.id).firstOrFail()
checkin.tokenHash = hashCheckinToken(plain)
checkin.status = 'sent'
checkin.sentAt = DateTime.now()
await checkin.save()
const beforeTasks = await RelanceTask.query().where('invoice_id', inv.id)
assert.lengthOf(beforeTasks, 0)
const pending = await client.get(`/api/v1/checkin/${plain}/pending`)
pending.assertStatus(200)
const invoice = await Invoice.findOrFail(inv.id)
const tasks = await RelanceTask.query()
.where('invoice_id', inv.id)
.preload('planStep')
.orderBy('send_at', 'asc')
assert.equal(invoice.organizationId, org.id)
assert.lengthOf(tasks, 3) assert.lengthOf(tasks, 3)
for (const t of tasks) assert.equal(t.status, 'scheduled') for (const t of tasks) assert.equal(t.status, 'scheduled')
assert.isAtMost(Math.abs(tasks[0].sendAt.diffNow('minutes').minutes), 2)
assert.isAbove(tasks[1].sendAt.toMillis(), tasks[0].sendAt.toMillis())
}) })
test('numéro unique par org (422 duplicate)', async ({ client }) => { test('numéro unique par org (422 duplicate)', async ({ client }) => {
@ -162,17 +199,14 @@ test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
test('idempotent : 2e appel ne re-bumpe pas rubisEarned', async ({ client, assert }) => { test('idempotent : 2e appel ne re-bumpe pas rubisEarned', async ({ client, assert }) => {
const { bearer, org } = await createTestUser() const { bearer, org } = await createTestUser()
const c = await createClient(client, bearer, 'Idem') const c = await createClient(client, bearer, 'Idem')
const created = await client const created = await client.post('/api/v1/invoices').headers(bearer).json({
.post('/api/v1/invoices') clientId: c.id,
.headers(bearer) clientName: c.name,
.json({ numero: 'F-2026-T10',
clientId: c.id, amountTtcCents: 100000,
clientName: c.name, issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-T10', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 100000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const id = body<ApiOk<InvoiceShape>>(created).data.id const id = body<ApiOk<InvoiceShape>>(created).data.id
const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer) const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer)
@ -190,25 +224,32 @@ test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
assert.equal(orgFresh.rubisCount, 1) assert.equal(orgFresh.rubisCount, 1)
}) })
test("annule les RelanceTasks scheduled de la facture", async ({ client, assert }) => { test('annule les RelanceTasks scheduled de la facture', async ({ client, assert }) => {
const { bearer } = await createTestUser() const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Cancel Tasks') const c = await createClient(client, bearer, 'Cancel Tasks')
const plan = await getStandardPlan(client, bearer) const plan = await getStandardPlan(client, bearer)
const created = await client const created = await client.post('/api/v1/invoices').headers(bearer).json({
.post('/api/v1/invoices') clientId: c.id,
.headers(bearer) clientName: c.name,
.json({ planId: plan.id,
clientId: c.id, numero: 'F-2026-T11',
clientName: c.name, amountTtcCents: 100000,
planId: plan.id, issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-T11', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 100000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const id = body<ApiOk<InvoiceShape>>(created).data.id const id = body<ApiOk<InvoiceShape>>(created).data.id
const plain = 'cancel-token-spec'
const checkin = await CheckinTask.query().where('invoice_id', id).firstOrFail()
checkin.tokenHash = hashCheckinToken(plain)
checkin.status = 'sent'
checkin.sentAt = DateTime.now()
await checkin.save()
const pending = await client.get(`/api/v1/checkin/${plain}/pending`)
pending.assertStatus(200)
const beforeTasks = await RelanceTask.query().where('invoice_id', id) const beforeTasks = await RelanceTask.query().where('invoice_id', id)
assert.lengthOf(beforeTasks, 3) assert.lengthOf(beforeTasks, 3)
@ -218,22 +259,17 @@ test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
for (const t of afterTasks) assert.equal(t.status, 'cancelled') for (const t of afterTasks) assert.equal(t.status, 'cancelled')
}) })
test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({ test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({ client }) => {
client,
}) => {
const { a, b } = await createTwoOrgs() const { a, b } = await createTwoOrgs()
const c = await createClient(client, a.bearer, 'Of A') const c = await createClient(client, a.bearer, 'Of A')
const created = await client const created = await client.post('/api/v1/invoices').headers(a.bearer).json({
.post('/api/v1/invoices') clientId: c.id,
.headers(a.bearer) clientName: c.name,
.json({ numero: 'F-2026-XO1',
clientId: c.id, amountTtcCents: 50000,
clientName: c.name, issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-XO1', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 50000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const id = body<ApiOk<InvoiceShape>>(created).data.id const id = body<ApiOk<InvoiceShape>>(created).data.id
const r = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(b.bearer) const r = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(b.bearer)
@ -273,17 +309,14 @@ test.group('Invoices — GET /invoices', (group) => {
const { bearer } = await createTestUser() const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Counts Spec') const c = await createClient(client, bearer, 'Counts Spec')
await client await client.post('/api/v1/invoices').headers(bearer).json({
.post('/api/v1/invoices') clientId: c.id,
.headers(bearer) clientName: c.name,
.json({ numero: 'F-2026-C01',
clientId: c.id, amountTtcCents: 10000,
clientName: c.name, issueDate: '2026-04-01T09:00:00.000Z',
numero: 'F-2026-C01', dueDate: '2026-05-01T09:00:00.000Z',
amountTtcCents: 10000, })
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const r = await client.get('/api/v1/invoices/counts').headers(bearer) const r = await client.get('/api/v1/invoices/counts').headers(bearer)
r.assertStatus(200) r.assertStatus(200)

View File

@ -1,3 +1,3 @@
VITE_API_URL=http://localhost:3333 VITE_API_URL=http://localhost:3333
VITE_PUBLIC_LANDING_URL=http://localhost:8080 VITE_PUBLIC_LANDING_URL=http://localhost:8080
VITE_USE_MOCKS=true VITE_USE_MOCKS=false

View File

@ -1,10 +1,8 @@
# URL de l'API AdonisJS. En dev local, MSW intercepte les requêtes — # URL de l'API AdonisJS.
# cette URL n'est utilisée que comme base path symbolique tant que le backend
# n'est pas branché.
VITE_API_URL=http://localhost:3333 VITE_API_URL=http://localhost:3333
# URL de la landing publique (lien retour depuis l'app) # URL de la landing publique (lien retour depuis l'app)
VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr
# Active MSW pour mocker l'API. Mettre à "false" pour taper le vrai backend. # Active MSW pour mocker l'API. Laisser à "false" pour taper le vrai backend.
VITE_USE_MOCKS=true VITE_USE_MOCKS=false

View File

@ -1,22 +1,19 @@
import type { AuthSession } from "@rubis/shared";
import { env } from "./env"; import { env } from "./env";
import { authStore } from "./auth"; import { authStore } from "./auth";
/** /**
* Client HTTP minimal placeholder en attendant que le client Tuyau * Client HTTP façade unique pour l'API REST. Centralise :
* soit branché contre le code Adonis (cf. /docs/tech/frontend.md §6). * - Header Authorization (Bearer token depuis authStore)
* - Cookie refresh httpOnly (`credentials: 'include'`)
* - Silent refresh sur 401 (rotation du token + retry une fois)
* - Gestion d'erreur uniforme via la classe ApiError
* *
* Tant que MSW intercepte ou que l'API n'est pas prête, on tape via fetch * Le contrat de réponse Adonis est `{ data: ... }` (cf. backend.md §6).
* sur baseUrl/api/v1/... et on sérialise/désérialise nous-mêmes. * Les erreurs sont `{ errors: [{ code, message, field? }] }`.
* *
* Une fois Tuyau opérationnel : * En dev avec VITE_USE_MOCKS=true, MSW intercepte transparently ce
* import { createTuyau } from "@tuyau/client" * fichier fonctionne pareil dans les deux modes.
* import { api } from "@rubis/api/registry"
* export const tuyau = createTuyau({ api, baseUrl: env.VITE_API_URL, ... })
*
* On gardera ce fichier comme façade pour pouvoir centraliser :
* - l'auth header
* - le retry sur 401 (silent refresh)
* - la gestion d'erreur uniforme
*/ */
export class ApiError extends Error { export class ApiError extends Error {
@ -39,12 +36,45 @@ type RequestOptions = {
anonymous?: boolean; anonymous?: boolean;
}; };
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { /**
* Refresh en cours partagé entre toutes les requêtes 401 simultanées :
* si N requêtes reviennent 401 en même temps, on n'envoie qu'un seul
* /auth/refresh et toutes attendent le même résultat.
*/
let pendingRefresh: Promise<void> | null = null;
async function performRefresh(): Promise<void> {
if (!pendingRefresh) {
pendingRefresh = (async () => {
try {
const session = await rawRequest<AuthSession>("/api/v1/auth/refresh", {
method: "POST",
anonymous: true,
});
authStore.setSession(session.accessToken, session.user);
} catch (err) {
authStore.clear();
throw err;
} finally {
pendingRefresh = null;
}
})();
}
return pendingRefresh;
}
/**
* Bas niveau : fait la requête sans tenter de silent refresh.
* Utilisé par performRefresh elle-même (sinon boucle infinie sur
* /auth/refresh qui revient 401).
*/
async function rawRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, signal, anonymous = false } = options; const { method = "GET", body, signal, anonymous = false } = options;
const isFormData = body instanceof FormData;
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/json", Accept: "application/json",
}; };
if (body !== undefined) headers["Content-Type"] = "application/json"; if (body !== undefined && !isFormData) headers["Content-Type"] = "application/json";
if (!anonymous && authStore.token) { if (!anonymous && authStore.token) {
headers.Authorization = `Bearer ${authStore.token}`; headers.Authorization = `Bearer ${authStore.token}`;
} }
@ -55,7 +85,12 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
method, method,
headers, headers,
credentials: "include", credentials: "include",
body: body !== undefined ? JSON.stringify(body) : undefined, body:
body === undefined
? undefined
: isFormData
? body
: JSON.stringify(body),
signal, signal,
}); });
@ -86,17 +121,64 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
); );
} }
// Convention de réponse Adonis : { data: ..., meta?: ... } // Convention de réponse Adonis : { data: ..., meta?: ... }. On extrait
// `data` quand il est présent (contrat documenté), sinon on renvoie
// le body tel quel (cas rare : endpoint qui retourne un objet plat).
return (json?.data ?? json) as T; return (json?.data ?? json) as T;
} }
/**
* Niveau public : ajoute le silent refresh sur 401.
*
* Si une requête authentifiée revient 401 :
* 1. On lance (ou attend) un seul /auth/refresh
* 2. Si le refresh réussit, on retry la requête originale avec le
* nouveau token
* 3. Si le refresh échoue, l'authStore est cleared (le router guard
* redirige vers /login) et on propage le 401 original
*
* Les requêtes anonymes (signup, login, refresh lui-même) ne tentent
* pas de refresh elles n'ont pas de token à rafraîchir.
*/
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try {
return await rawRequest<T>(path, options);
} catch (err) {
const isAuthEndpoint = path.startsWith("/api/v1/auth/");
const shouldRefresh =
err instanceof ApiError &&
err.status === 401 &&
!options.anonymous &&
!isAuthEndpoint &&
authStore.token !== null;
if (!shouldRefresh) throw err;
try {
await performRefresh();
} catch {
// refresh KO → on propage le 401 original (le store est déjà cleared)
throw err;
}
// Retry une seule fois avec le nouveau token.
return rawRequest<T>(path, options);
}
}
export const api = { export const api = {
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> => get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "GET" }), request<T>(path, { ...options, method: "GET" }),
post: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> => post: <T>(
request<T>(path, { ...options, method: "POST", body }), path: string,
patch: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> => body?: unknown,
request<T>(path, { ...options, method: "PATCH", body }), options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> => request<T>(path, { ...options, method: "POST", body }),
patch: <T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> => delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "DELETE" }), request<T>(path, { ...options, method: "DELETE" }),
}; };

View File

@ -0,0 +1,14 @@
import { api } from "@/lib/api";
export type ImportBatchResponse = {
id: string;
drafts: Array<{ id: string; filename: string }>;
};
export function uploadInvoiceFiles(files: File[]): Promise<ImportBatchResponse> {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
return api.post<ImportBatchResponse>("/api/v1/invoices/upload", formData);
}

View File

@ -118,6 +118,27 @@ const uploadSchema = z.object({
filenames: z.array(z.string().min(1)).min(1).max(20), filenames: z.array(z.string().min(1)).min(1).max(20),
}); });
async function filenamesFromUploadRequest(request: Request): Promise<string[]> {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.startsWith("multipart/")) {
return request
.formData()
.then((formData) =>
formData
.getAll("files")
.filter((file): file is File => file instanceof File)
.map((file) => file.name),
);
}
const json = await request.json();
const parsed = uploadSchema.safeParse(json);
if (!parsed.success) {
throw parsed.error;
}
return parsed.data.filenames;
}
const draftFieldsSchema = z.object({ const draftFieldsSchema = z.object({
clientId: z.string().nullable(), clientId: z.string().nullable(),
clientName: z.string().min(1).max(120), clientName: z.string().min(1).max(120),
@ -420,12 +441,14 @@ export const invoiceHandlers = [
const orgId = authedOrgId(request.headers.get("authorization")); const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated(); if (!orgId) return unauthenticated();
const json = await request.json(); let filenames: string[];
const parsed = uploadSchema.safeParse(json); try {
if (!parsed.success) { filenames = await filenamesFromUploadRequest(request);
uploadSchema.parse({ filenames });
} catch (err) {
return HttpResponse.json( return HttpResponse.json(
{ {
errors: parsed.error.issues.map((i) => ({ errors: (err instanceof z.ZodError ? err.issues : []).map((i) => ({
code: "validation_failed", code: "validation_failed",
message: i.message, message: i.message,
})), })),
@ -438,7 +461,7 @@ export const invoiceHandlers = [
const plans = mockDb.listPlansForOrg(orgId); const plans = mockDb.listPlansForOrg(orgId);
const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null; const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null;
const drafts = parsed.data.filenames.map((filename) => { const drafts = filenames.map((filename) => {
const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId); const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId);
return { filename, extracted, confidence }; return { filename, extracted, confidence };
}); });

View File

@ -18,11 +18,7 @@ import {
} from "@/components/factures/InvoiceTable"; } from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList"; import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog"; import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices";
type ImportBatchResponse = {
id: string;
drafts: Array<{ id: string; filename: string }>;
};
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */ /** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
const FILTER_KEYS = [ const FILTER_KEYS = [
@ -64,10 +60,7 @@ export const Route = createFileRoute("/_app/factures")({
function useUploadInvoices() { function useUploadInvoices() {
const navigate = useNavigate(); const navigate = useNavigate();
return useMutation({ return useMutation({
mutationFn: (files: File[]) => mutationFn: uploadInvoiceFiles,
api.post<ImportBatchResponse>("/api/v1/invoices/upload", {
filenames: files.map((f) => f.name),
}),
onSuccess: (batch) => { onSuccess: (batch) => {
toast.success( toast.success(
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${ `${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${

View File

@ -3,16 +3,11 @@ import { useMutation } from "@tanstack/react-query";
import { ArrowLeft, FilePlus } from "lucide-react"; import { ArrowLeft, FilePlus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Eyebrow } from "@/components/ui/Eyebrow"; import { Eyebrow } from "@/components/ui/Eyebrow";
import { Dropzone } from "@/components/factures/Dropzone"; import { Dropzone } from "@/components/factures/Dropzone";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog"; import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices";
type ImportBatchResponse = {
id: string;
drafts: Array<{ id: string; filename: string }>;
};
export const Route = createFileRoute("/_app/factures_/import")({ export const Route = createFileRoute("/_app/factures_/import")({
component: ImportLandingPage, component: ImportLandingPage,
@ -23,10 +18,7 @@ function ImportLandingPage() {
const manual = useManualInvoice(); const manual = useManualInvoice();
const upload = useMutation({ const upload = useMutation({
mutationFn: (files: File[]) => mutationFn: uploadInvoiceFiles,
api.post<ImportBatchResponse>("/api/v1/invoices/upload", {
filenames: files.map((f) => f.name),
}),
onSuccess: (batch) => { onSuccess: (batch) => {
toast.success( toast.success(
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${ `${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${