From 5e41e2a9fa5fd3bbd70c29037d5bb562e2a1bf76 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 18:47:35 +0200 Subject: [PATCH] add ocr + add factures --- .claude/settings.local.json | 8 + AGENTS.md | 169 +++++++++++++ apps/api/.env.example | 4 +- .../api/app/controllers/checkin_controller.ts | 17 +- .../controllers/import_batches_controller.ts | 15 +- .../app/controllers/invoices_controller.ts | 75 +++--- .../app/services/ocr/mistral_ocr_provider.ts | 35 ++- apps/api/app/services/relance_scheduler.ts | 33 ++- apps/api/tests/functional/invoices.spec.ts | 229 ++++++++++-------- apps/web/.env.development | 2 +- apps/web/.env.example | 8 +- apps/web/src/lib/api.ts | 124 ++++++++-- apps/web/src/lib/invoices.ts | 14 ++ apps/web/src/mocks/handlers/invoices.ts | 33 ++- apps/web/src/routes/_app/factures.tsx | 11 +- apps/web/src/routes/_app/factures_.import.tsx | 12 +- 16 files changed, 566 insertions(+), 223 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 AGENTS.md create mode 100644 apps/web/src/lib/invoices.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a3da492 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm -F api typecheck)", + "Bash(pnpm -F @rubis/web typecheck)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d79f38c --- /dev/null +++ b/AGENTS.md @@ -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 (500–800), Google Fonts | +| **Typo body** | Inter (400–700), 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.* diff --git a/apps/api/.env.example b/apps/api/.env.example index 55bb192..57ccd8d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -61,7 +61,7 @@ RESEND_API_KEY= #-------------------------------------------------------------------- # OCR (Mistral) #-------------------------------------------------------------------- -OCR_PROVIDER=mock +OCR_PROVIDER=mistral MISTRAL_API_KEY= #-------------------------------------------------------------------- @@ -76,4 +76,4 @@ ACCESS_TOKEN_TTL_MINUTES=30 REFRESH_TOKEN_TTL_DAYS=30 COOKIE_DOMAIN= COOKIE_SECURE=false -LIMITER_STORE=redis \ No newline at end of file +LIMITER_STORE=redis diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts index 8575345..8311f90 100644 --- a/apps/api/app/controllers/checkin_controller.ts +++ b/apps/api/app/controllers/checkin_controller.ts @@ -2,7 +2,7 @@ import CheckinTask from '#models/checkin_task' import Invoice from '#models/invoice' import { hashCheckinToken } from '#services/checkin_token' 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 env from '#start/env' import { DateTime } from 'luxon' @@ -24,9 +24,7 @@ function spaRedirectUrl( return `${base}/?${params.toString()}` } -type ResolvedTask = - | { task: CheckinTask; invoice: Invoice } - | { redirect: string } +type ResolvedTask = { task: CheckinTask; invoice: Invoice } | { redirect: string } /** * Lookup + validation commune aux deux endpoints (paid / pending). @@ -52,10 +50,7 @@ async function resolveCheckin(token: string): Promise { return { redirect: spaRedirectUrl('expired') } } - const invoice = await Invoice.query() - .where('id', task.invoiceId) - .preload('client') - .first() + const invoice = await Invoice.query().where('id', task.invoiceId).preload('client').first() if (!invoice) { return { redirect: spaRedirectUrl('invalid') } } @@ -119,7 +114,7 @@ export default class CheckinController { * GET /api/v1/checkin/:token/pending * * 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) { const result = await resolveCheckin(params.token) @@ -133,6 +128,10 @@ export default class CheckinController { task.answeredAt = DateTime.now() await task.save() + if (invoice.planId) { + await scheduleRelancesForInvoice(invoice) + } + return response.redirect(spaRedirectUrl('pending', invoice.numero)) } } diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts index 1fdbee6..352cb38 100644 --- a/apps/api/app/controllers/import_batches_controller.ts +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -1,14 +1,9 @@ import ImportBatch from '#models/import_batch' import Invoice from '#models/invoice' import Plan from '#models/plan' -import ImportBatchTransformer, { - serializeDraft, -} from '#transformers/import_batch_transformer' +import ImportBatchTransformer, { serializeDraft } from '#transformers/import_batch_transformer' import InvoiceTransformer from '#transformers/invoice_transformer' -import { - uploadValidator, - validateDraftValidator, -} from '#validators/import_batch' +import { uploadValidator, validateDraftValidator } from '#validators/import_batch' import { resolveClient } from '#services/resolve_client' import { createImportBatch, @@ -16,7 +11,6 @@ import { type ImportSource, } from '#services/import_batch' import { recordActivity } from '#services/activity_recorder' -import { scheduleRelancesForInvoice } from '#services/relance_scheduler' import { scheduleCheckinForInvoice } from '#services/checkin_scheduler' import logger from '@adonisjs/core/services/logger' import drive from '@adonisjs/drive/services/main' @@ -225,12 +219,9 @@ export default class ImportBatchesController { await invoice.load('plan') try { - if (invoice.planId) { - await scheduleRelancesForInvoice(invoice) - } await scheduleCheckinForInvoice(invoice) } 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() }) diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 2b532de..c6b41e0 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -1,24 +1,16 @@ import Invoice from '#models/invoice' import Plan from '#models/plan' +import RelanceTask from '#models/relance_task' import InvoiceTransformer from '#transformers/invoice_transformer' -import { - createInvoiceValidator, - listInvoicesValidator, -} from '#validators/invoice' +import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import db from '@adonisjs/lucid/services/db' import { DateTime } from 'luxon' import { resolveClient } from '#services/resolve_client' import { recordActivity } from '#services/activity_recorder' -import { - scheduleRelancesForInvoice, - cancelFutureRelances, -} from '#services/relance_scheduler' -import { - scheduleCheckinForInvoice, - cancelCheckinForInvoice, -} from '#services/checkin_scheduler' +import { cancelFutureRelances } from '#services/relance_scheduler' +import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler' import logger from '@adonisjs/core/services/logger' const PAGE_SIZE = 50 @@ -53,7 +45,10 @@ function serializeInvoice(i: Invoice) { * - étape actuelle (la prochaine future) : 'current' * - étapes futures : 'future' */ -function buildTimeline(invoice: Invoice): Array<{ +function buildTimeline( + invoice: Invoice, + relanceTasks: RelanceTask[] = [] +): Array<{ id: string state: 'past' | 'current' | 'future' when: string @@ -73,35 +68,43 @@ function buildTimeline(invoice: Invoice): Array<{ }, ] - if ( - invoice.plan?.steps?.length && - invoice.status !== 'paid' && - invoice.status !== 'cancelled' - ) { + if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') { const dueMs = invoice.dueDate.toMillis() const nowMs = DateTime.now().toMillis() + const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task])) let currentSet = false 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 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}` let state: 'past' | 'current' | 'future' - if (sendMs < nowMs) state = 'past' - else if (!currentSet) { + if (task?.status === 'sent') state = 'past' + 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' currentSet = true } 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({ id: `${invoice.id}__step_${step.order}`, state, when: `${formatShortDate(stepDate)} · ${labelStep}`, - what: - state === 'past' - ? `Email envoyé · "${step.subject.replace('{{numero}}', invoice.numero)}"` - : `Email programmé · "${step.subject.replace('{{numero}}', invoice.numero)}"`, + what, }) } } @@ -223,6 +226,9 @@ export default class InvoicesController { } const data = serializeInvoice(invoice) + const relanceTasks = await RelanceTask.query() + .where('invoice_id', invoice.id) + .whereNot('status', 'cancelled') return response.json({ data: { ...data, @@ -251,7 +257,7 @@ export default class InvoicesController { 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('plan') - // Programme les relances BullMQ si la facture a un plan + le check-in - // (envoyé pile à dueDate). Hors tx — Redis ne participe pas aux - // 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. + // Programme uniquement le check-in (envoyé à dueDate). Les relances + // client ne partent qu'après confirmation "toujours en attente". try { - if (invoice.planId) { - await scheduleRelancesForInvoice(invoice) - } await scheduleCheckinForInvoice(invoice) } 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) }) @@ -353,10 +353,7 @@ export default class InvoicesController { await invoice.save() // Bump du compteur agrégé sur l'organisation - await trx - .from('organizations') - .where('id', organizationId) - .increment('rubis_count', 1) + await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1) // Journal d'activité (cf. dashboard activity feed). await recordActivity({ diff --git a/apps/api/app/services/ocr/mistral_ocr_provider.ts b/apps/api/app/services/ocr/mistral_ocr_provider.ts index 52c9be5..775b443 100644 --- a/apps/api/app/services/ocr/mistral_ocr_provider.ts +++ b/apps/api/app/services/ocr/mistral_ocr_provider.ts @@ -8,6 +8,13 @@ const OCR_MODEL = 'mistral-ocr-latest' // Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict). const EXTRACTION_MODEL = 'mistral-large-latest' +const MIME_BY_EXT: Record = { + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', +} + 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 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 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 const ocrJson = await this.postJson('/ocr', { model: OCR_MODEL, - document: { type: 'document_url', document_url: dataUri }, + document: this.documentPayload(dataUri, mimeType), }) const markdown = (ocrJson?.pages ?? []) .map((p: { markdown?: string }) => p.markdown ?? '') @@ -93,6 +101,27 @@ export class MistralOcrProvider implements OcrProvider { 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 { const res = await fetch(`${MISTRAL_API}${path}`, { method: 'POST', diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts index e3ec1b4..621948a 100644 --- a/apps/api/app/services/relance_scheduler.ts +++ b/apps/api/app/services/relance_scheduler.ts @@ -1,7 +1,7 @@ import { DateTime } from 'luxon' import RelanceTask from '#models/relance_task' import Plan from '#models/plan' -import Invoice from '#models/invoice' +import type Invoice from '#models/invoice' import { getQueue } from '#services/queue' import app from '@adonisjs/core/services/app' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' @@ -25,10 +25,10 @@ function shouldEnqueue(): boolean { * - Crée une RelanceTask `scheduled` * - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now * - * Si sendAt est dans le passé (cas : facture importée avec une dueDate - * ancienne), on programme quand même la task pour `now + 1 min` — l'user - * est probablement en train de "rattraper" un retard, l'envoi immédiat - * est cohérent. + * Si une facture est déjà en retard quand l'utilisateur confirme "toujours + * en attente", on n'envoie pas toutes les étapes passées d'un coup : + * la première étape éligible part à `now + 1 min`, puis les suivantes + * gardent l'écart du plan à partir de ce nouveau départ. * * Idempotent par invoice.id : si des tasks `scheduled` existent déjà * pour cette facture, on les annule avant de re-programmer (cas où on @@ -58,17 +58,32 @@ export async function scheduleRelancesForInvoice( // Ignore — le job peut déjà être consommé. }) } - t.useTransaction(trx ?? null as never) + t.useTransaction(trx ?? (null as never)) t.status = 'cancelled' await t.save() } const now = DateTime.now() 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 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( { @@ -128,7 +143,7 @@ export async function cancelFutureRelances( if (t.queueJobId && queue) { await queue.remove(t.queueJobId).catch(() => {}) } - t.useTransaction(trx ?? null as never) + t.useTransaction(trx ?? (null as never)) t.status = 'cancelled' await t.save() } diff --git a/apps/api/tests/functional/invoices.spec.ts b/apps/api/tests/functional/invoices.spec.ts index 349c91c..05fd33c 100644 --- a/apps/api/tests/functional/invoices.spec.ts +++ b/apps/api/tests/functional/invoices.spec.ts @@ -1,9 +1,13 @@ import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import RelanceTask from '#models/relance_task' +import CheckinTask from '#models/checkin_task' +import Invoice from '#models/invoice' import Organization from '#models/organization' +import { hashCheckinToken } from '#services/checkin_token' import { createTestUser, createTwoOrgs } from '../helpers/auth.js' import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js' +import { DateTime } from 'luxon' type InvoiceShape = { id: string @@ -35,24 +39,18 @@ async function getStandardPlan(client: any, headers: Record) { test.group('Invoices — POST /invoices', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) - test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({ - client, - assert, - }) => { + test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({ client, assert }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Boulangerie Spec') - const r = await client - .post('/api/v1/invoices') - .headers(bearer) - .json({ - clientId: c.id, - clientName: c.name, - numero: 'F-2026-T01', - amountTtcCents: 124000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + const r = await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: c.id, + clientName: c.name, + numero: 'F-2026-T01', + amountTtcCents: 124000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) r.assertStatus(201) const inv = body>(r).data @@ -60,23 +58,17 @@ test.group('Invoices — POST /invoices', (group) => { assert.equal(inv.status, 'pending') }) - test('crée à la volée un client si nom non matché + email fourni', async ({ - client, - assert, - }) => { + test('crée à la volée un client si nom non matché + email fourni', async ({ client, assert }) => { const { bearer, accessToken } = await createTestUser() - const r = await client - .post('/api/v1/invoices') - .headers(bearer) - .json({ - clientName: 'Nouveau Client', - clientEmail: 'nouveau@spec.test', - numero: 'F-2026-T02', - amountTtcCents: 50000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + const r = await client.post('/api/v1/invoices').headers(bearer).json({ + clientName: 'Nouveau Client', + clientEmail: 'nouveau@spec.test', + numero: 'F-2026-T02', + amountTtcCents: 50000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) r.assertStatus(201) @@ -95,46 +87,91 @@ test.group('Invoices — POST /invoices', (group) => { }) => { const { bearer } = await createTestUser() - const r = await client - .post('/api/v1/invoices') - .headers(bearer) - .json({ - clientName: 'Sans Email', - numero: 'F-2026-T03', - amountTtcCents: 50000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + const r = await client.post('/api/v1/invoices').headers(bearer).json({ + clientName: 'Sans Email', + numero: 'F-2026-T03', + amountTtcCents: 50000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) r.assertStatus(422) const errors = body<{ errors: Array<{ code: string }> }>(r).errors 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 c = await createClient(client, bearer, 'Avec Plan') 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>(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') .headers(bearer) .json({ clientId: c.id, clientName: c.name, planId: plan.id, - numero: 'F-2026-T04', + numero: 'F-2026-CHECKIN', amountTtcCents: 50000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', + issueDate: dueDate.minus({ days: 15 }).toISO(), + dueDate: dueDate.toISO(), }) - r.assertStatus(201) - const inv = body>(r).data - const tasks = await RelanceTask.query().where('invoice_id', inv.id) - // Le plan standard-30j a 3 steps + created.assertStatus(201) + const inv = body>(created).data + const plain = 'pending-token-spec' + 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) 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 }) => { @@ -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 }) => { const { bearer, org } = await createTestUser() const c = await createClient(client, bearer, 'Idem') - const created = await client - .post('/api/v1/invoices') - .headers(bearer) - .json({ - clientId: c.id, - clientName: c.name, - numero: 'F-2026-T10', - amountTtcCents: 100000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + const created = await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: c.id, + clientName: c.name, + numero: 'F-2026-T10', + amountTtcCents: 100000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) const id = body>(created).data.id 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) }) - 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 c = await createClient(client, bearer, 'Cancel Tasks') const plan = await getStandardPlan(client, bearer) - const created = await client - .post('/api/v1/invoices') - .headers(bearer) - .json({ - clientId: c.id, - clientName: c.name, - planId: plan.id, - numero: 'F-2026-T11', - amountTtcCents: 100000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + const created = await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: c.id, + clientName: c.name, + planId: plan.id, + numero: 'F-2026-T11', + amountTtcCents: 100000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) const id = body>(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) 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') }) - test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({ - client, - }) => { + test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({ client }) => { const { a, b } = await createTwoOrgs() const c = await createClient(client, a.bearer, 'Of A') - const created = await client - .post('/api/v1/invoices') - .headers(a.bearer) - .json({ - clientId: c.id, - clientName: c.name, - numero: 'F-2026-XO1', - amountTtcCents: 50000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + const created = await client.post('/api/v1/invoices').headers(a.bearer).json({ + clientId: c.id, + clientName: c.name, + numero: 'F-2026-XO1', + amountTtcCents: 50000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) const id = body>(created).data.id 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 c = await createClient(client, bearer, 'Counts Spec') - await client - .post('/api/v1/invoices') - .headers(bearer) - .json({ - clientId: c.id, - clientName: c.name, - numero: 'F-2026-C01', - amountTtcCents: 10000, - issueDate: '2026-04-01T09:00:00.000Z', - dueDate: '2026-05-01T09:00:00.000Z', - }) + await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: c.id, + clientName: c.name, + numero: 'F-2026-C01', + 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) r.assertStatus(200) diff --git a/apps/web/.env.development b/apps/web/.env.development index e809d3c..e55b08a 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -1,3 +1,3 @@ VITE_API_URL=http://localhost:3333 VITE_PUBLIC_LANDING_URL=http://localhost:8080 -VITE_USE_MOCKS=true +VITE_USE_MOCKS=false diff --git a/apps/web/.env.example b/apps/web/.env.example index 8d4a902..301eabe 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,10 +1,8 @@ -# URL de l'API AdonisJS. En dev local, MSW intercepte les requêtes — -# cette URL n'est utilisée que comme base path symbolique tant que le backend -# n'est pas branché. +# URL de l'API AdonisJS. VITE_API_URL=http://localhost:3333 # URL de la landing publique (lien retour depuis l'app) VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr -# Active MSW pour mocker l'API. Mettre à "false" pour taper le vrai backend. -VITE_USE_MOCKS=true +# Active MSW pour mocker l'API. Laisser à "false" pour taper le vrai backend. +VITE_USE_MOCKS=false diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6e14a53..3f74371 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,22 +1,19 @@ +import type { AuthSession } from "@rubis/shared"; import { env } from "./env"; import { authStore } from "./auth"; /** - * Client HTTP minimal — placeholder en attendant que le client Tuyau - * soit branché contre le code Adonis (cf. /docs/tech/frontend.md §6). + * Client HTTP — façade unique pour l'API REST. Centralise : + * - 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 - * sur baseUrl/api/v1/... et on sérialise/désérialise nous-mêmes. + * Le contrat de réponse Adonis est `{ data: ... }` (cf. backend.md §6). + * Les erreurs sont `{ errors: [{ code, message, field? }] }`. * - * Une fois Tuyau opérationnel : - * import { createTuyau } from "@tuyau/client" - * 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 + * En dev avec VITE_USE_MOCKS=true, MSW intercepte transparently — ce + * fichier fonctionne pareil dans les deux modes. */ export class ApiError extends Error { @@ -39,12 +36,45 @@ type RequestOptions = { anonymous?: boolean; }; -async function request(path: string, options: RequestOptions = {}): Promise { +/** + * 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 | null = null; + +async function performRefresh(): Promise { + if (!pendingRefresh) { + pendingRefresh = (async () => { + try { + const session = await rawRequest("/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(path: string, options: RequestOptions = {}): Promise { const { method = "GET", body, signal, anonymous = false } = options; + const isFormData = body instanceof FormData; const headers: Record = { Accept: "application/json", }; - if (body !== undefined) headers["Content-Type"] = "application/json"; + if (body !== undefined && !isFormData) headers["Content-Type"] = "application/json"; if (!anonymous && authStore.token) { headers.Authorization = `Bearer ${authStore.token}`; } @@ -55,7 +85,12 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise { + try { + return await rawRequest(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(path, options); + } +} + export const api = { get: (path: string, options?: Omit): Promise => request(path, { ...options, method: "GET" }), - post: (path: string, body?: unknown, options?: Omit): Promise => - request(path, { ...options, method: "POST", body }), - patch: (path: string, body?: unknown, options?: Omit): Promise => - request(path, { ...options, method: "PATCH", body }), + post: ( + path: string, + body?: unknown, + options?: Omit, + ): Promise => request(path, { ...options, method: "POST", body }), + patch: ( + path: string, + body?: unknown, + options?: Omit, + ): Promise => request(path, { ...options, method: "PATCH", body }), delete: (path: string, options?: Omit): Promise => request(path, { ...options, method: "DELETE" }), }; diff --git a/apps/web/src/lib/invoices.ts b/apps/web/src/lib/invoices.ts new file mode 100644 index 0000000..d26ddf8 --- /dev/null +++ b/apps/web/src/lib/invoices.ts @@ -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 { + const formData = new FormData(); + for (const file of files) { + formData.append("files", file); + } + return api.post("/api/v1/invoices/upload", formData); +} diff --git a/apps/web/src/mocks/handlers/invoices.ts b/apps/web/src/mocks/handlers/invoices.ts index 565bca3..c9cc8a5 100644 --- a/apps/web/src/mocks/handlers/invoices.ts +++ b/apps/web/src/mocks/handlers/invoices.ts @@ -118,6 +118,27 @@ const uploadSchema = z.object({ filenames: z.array(z.string().min(1)).min(1).max(20), }); +async function filenamesFromUploadRequest(request: Request): Promise { + 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({ clientId: z.string().nullable(), clientName: z.string().min(1).max(120), @@ -420,12 +441,14 @@ export const invoiceHandlers = [ const orgId = authedOrgId(request.headers.get("authorization")); if (!orgId) return unauthenticated(); - const json = await request.json(); - const parsed = uploadSchema.safeParse(json); - if (!parsed.success) { + let filenames: string[]; + try { + filenames = await filenamesFromUploadRequest(request); + uploadSchema.parse({ filenames }); + } catch (err) { return HttpResponse.json( { - errors: parsed.error.issues.map((i) => ({ + errors: (err instanceof z.ZodError ? err.issues : []).map((i) => ({ code: "validation_failed", message: i.message, })), @@ -438,7 +461,7 @@ export const invoiceHandlers = [ const plans = mockDb.listPlansForOrg(orgId); 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); return { filename, extracted, confidence }; }); diff --git a/apps/web/src/routes/_app/factures.tsx b/apps/web/src/routes/_app/factures.tsx index 7b9a4c8..e70cb3d 100644 --- a/apps/web/src/routes/_app/factures.tsx +++ b/apps/web/src/routes/_app/factures.tsx @@ -18,11 +18,7 @@ import { } from "@/components/factures/InvoiceTable"; import { InvoiceCardList } from "@/components/factures/InvoiceCardList"; import { useManualInvoice } from "@/hooks/useManualInvoiceDialog"; - -type ImportBatchResponse = { - id: string; - drafts: Array<{ id: string; filename: string }>; -}; +import { uploadInvoiceFiles } from "@/lib/invoices"; /** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */ const FILTER_KEYS = [ @@ -64,10 +60,7 @@ export const Route = createFileRoute("/_app/factures")({ function useUploadInvoices() { const navigate = useNavigate(); return useMutation({ - mutationFn: (files: File[]) => - api.post("/api/v1/invoices/upload", { - filenames: files.map((f) => f.name), - }), + mutationFn: uploadInvoiceFiles, onSuccess: (batch) => { toast.success( `${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${ diff --git a/apps/web/src/routes/_app/factures_.import.tsx b/apps/web/src/routes/_app/factures_.import.tsx index a4ab672..0eabf07 100644 --- a/apps/web/src/routes/_app/factures_.import.tsx +++ b/apps/web/src/routes/_app/factures_.import.tsx @@ -3,16 +3,11 @@ import { useMutation } from "@tanstack/react-query"; import { ArrowLeft, FilePlus } from "lucide-react"; import { toast } from "sonner"; -import { api } from "@/lib/api"; import { Button } from "@/components/ui/Button"; import { Eyebrow } from "@/components/ui/Eyebrow"; import { Dropzone } from "@/components/factures/Dropzone"; import { useManualInvoice } from "@/hooks/useManualInvoiceDialog"; - -type ImportBatchResponse = { - id: string; - drafts: Array<{ id: string; filename: string }>; -}; +import { uploadInvoiceFiles } from "@/lib/invoices"; export const Route = createFileRoute("/_app/factures_/import")({ component: ImportLandingPage, @@ -23,10 +18,7 @@ function ImportLandingPage() { const manual = useManualInvoice(); const upload = useMutation({ - mutationFn: (files: File[]) => - api.post("/api/v1/invoices/upload", { - filenames: files.map((f) => f.name), - }), + mutationFn: uploadInvoiceFiles, onSuccess: (batch) => { toast.success( `${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${