add ocr + add factures

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

View File

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

169
AGENTS.md Normal file
View File

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

View File

@ -61,7 +61,7 @@ RESEND_API_KEY=
#--------------------------------------------------------------------
# OCR (Mistral)
#--------------------------------------------------------------------
OCR_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

View File

@ -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))
}
}

View File

@ -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() })

View File

@ -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({

View File

@ -8,6 +8,13 @@ const OCR_MODEL = 'mistral-ocr-latest'
// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict).
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',

View File

@ -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 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()
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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" }),
};

View File

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

View File

@ -118,6 +118,27 @@ const uploadSchema = z.object({
filenames: z.array(z.string().min(1)).min(1).max(20),
});
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 };
});

View File

@ -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${

View File

@ -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${