add ocr + add factures
This commit is contained in:
parent
c4486d9e5e
commit
5e41e2a9fa
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm -F api typecheck)",
|
||||
"Bash(pnpm -F @rubis/web typecheck)"
|
||||
]
|
||||
}
|
||||
}
|
||||
169
AGENTS.md
Normal file
169
AGENTS.md
Normal 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 (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.*
|
||||
@ -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
|
||||
LIMITER_STORE=redis
|
||||
|
||||
@ -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<ResolvedTask> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() })
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<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.
|
||||
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<any> {
|
||||
const res = await fetch(`${MISTRAL_API}${path}`, {
|
||||
method: 'POST',
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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<string, string>) {
|
||||
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<ApiOk<InvoiceShape>>(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<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')
|
||||
.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<ApiOk<InvoiceShape>>(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<ApiOk<InvoiceShape>>(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<ApiOk<InvoiceShape>>(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<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)
|
||||
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<ApiOk<InvoiceShape>>(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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<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 isFormData = body instanceof FormData;
|
||||
const headers: Record<string, string> = {
|
||||
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<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
body:
|
||||
body === undefined
|
||||
? undefined
|
||||
: isFormData
|
||||
? body
|
||||
: JSON.stringify(body),
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||
request<T>(path, { ...options, method: "GET" }),
|
||||
post: <T>(path: string, body?: unknown, 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 }),
|
||||
post: <T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
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> =>
|
||||
request<T>(path, { ...options, method: "DELETE" }),
|
||||
};
|
||||
|
||||
14
apps/web/src/lib/invoices.ts
Normal file
14
apps/web/src/lib/invoices.ts
Normal 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);
|
||||
}
|
||||
@ -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<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({
|
||||
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 };
|
||||
});
|
||||
|
||||
@ -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<ImportBatchResponse>("/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${
|
||||
|
||||
@ -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<ImportBatchResponse>("/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${
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user