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 (Mistral)
|
||||||
#--------------------------------------------------------------------
|
#--------------------------------------------------------------------
|
||||||
OCR_PROVIDER=mock
|
OCR_PROVIDER=mistral
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
|
|
||||||
#--------------------------------------------------------------------
|
#--------------------------------------------------------------------
|
||||||
@ -76,4 +76,4 @@ ACCESS_TOKEN_TTL_MINUTES=30
|
|||||||
REFRESH_TOKEN_TTL_DAYS=30
|
REFRESH_TOKEN_TTL_DAYS=30
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
LIMITER_STORE=redis
|
LIMITER_STORE=redis
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import CheckinTask from '#models/checkin_task'
|
|||||||
import Invoice from '#models/invoice'
|
import Invoice from '#models/invoice'
|
||||||
import { hashCheckinToken } from '#services/checkin_token'
|
import { hashCheckinToken } from '#services/checkin_token'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { cancelFutureRelances } from '#services/relance_scheduler'
|
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
@ -24,9 +24,7 @@ function spaRedirectUrl(
|
|||||||
return `${base}/?${params.toString()}`
|
return `${base}/?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolvedTask =
|
type ResolvedTask = { task: CheckinTask; invoice: Invoice } | { redirect: string }
|
||||||
| { task: CheckinTask; invoice: Invoice }
|
|
||||||
| { redirect: string }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup + validation commune aux deux endpoints (paid / pending).
|
* Lookup + validation commune aux deux endpoints (paid / pending).
|
||||||
@ -52,10 +50,7 @@ async function resolveCheckin(token: string): Promise<ResolvedTask> {
|
|||||||
return { redirect: spaRedirectUrl('expired') }
|
return { redirect: spaRedirectUrl('expired') }
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoice = await Invoice.query()
|
const invoice = await Invoice.query().where('id', task.invoiceId).preload('client').first()
|
||||||
.where('id', task.invoiceId)
|
|
||||||
.preload('client')
|
|
||||||
.first()
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
return { redirect: spaRedirectUrl('invalid') }
|
return { redirect: spaRedirectUrl('invalid') }
|
||||||
}
|
}
|
||||||
@ -119,7 +114,7 @@ export default class CheckinController {
|
|||||||
* GET /api/v1/checkin/:token/pending
|
* GET /api/v1/checkin/:token/pending
|
||||||
*
|
*
|
||||||
* L'utilisateur clique "toujours en attente". On marque la task
|
* L'utilisateur clique "toujours en attente". On marque la task
|
||||||
* answered, les relances suivent leur cours.
|
* answered, puis on programme les relances client.
|
||||||
*/
|
*/
|
||||||
async respondPending({ params, response }: HttpContext) {
|
async respondPending({ params, response }: HttpContext) {
|
||||||
const result = await resolveCheckin(params.token)
|
const result = await resolveCheckin(params.token)
|
||||||
@ -133,6 +128,10 @@ export default class CheckinController {
|
|||||||
task.answeredAt = DateTime.now()
|
task.answeredAt = DateTime.now()
|
||||||
await task.save()
|
await task.save()
|
||||||
|
|
||||||
|
if (invoice.planId) {
|
||||||
|
await scheduleRelancesForInvoice(invoice)
|
||||||
|
}
|
||||||
|
|
||||||
return response.redirect(spaRedirectUrl('pending', invoice.numero))
|
return response.redirect(spaRedirectUrl('pending', invoice.numero))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,9 @@
|
|||||||
import ImportBatch from '#models/import_batch'
|
import ImportBatch from '#models/import_batch'
|
||||||
import Invoice from '#models/invoice'
|
import Invoice from '#models/invoice'
|
||||||
import Plan from '#models/plan'
|
import Plan from '#models/plan'
|
||||||
import ImportBatchTransformer, {
|
import ImportBatchTransformer, { serializeDraft } from '#transformers/import_batch_transformer'
|
||||||
serializeDraft,
|
|
||||||
} from '#transformers/import_batch_transformer'
|
|
||||||
import InvoiceTransformer from '#transformers/invoice_transformer'
|
import InvoiceTransformer from '#transformers/invoice_transformer'
|
||||||
import {
|
import { uploadValidator, validateDraftValidator } from '#validators/import_batch'
|
||||||
uploadValidator,
|
|
||||||
validateDraftValidator,
|
|
||||||
} from '#validators/import_batch'
|
|
||||||
import { resolveClient } from '#services/resolve_client'
|
import { resolveClient } from '#services/resolve_client'
|
||||||
import {
|
import {
|
||||||
createImportBatch,
|
createImportBatch,
|
||||||
@ -16,7 +11,6 @@ import {
|
|||||||
type ImportSource,
|
type ImportSource,
|
||||||
} from '#services/import_batch'
|
} from '#services/import_batch'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
|
||||||
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import drive from '@adonisjs/drive/services/main'
|
import drive from '@adonisjs/drive/services/main'
|
||||||
@ -225,12 +219,9 @@ export default class ImportBatchesController {
|
|||||||
await invoice.load('plan')
|
await invoice.load('plan')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (invoice.planId) {
|
|
||||||
await scheduleRelancesForInvoice(invoice)
|
|
||||||
}
|
|
||||||
await scheduleCheckinForInvoice(invoice)
|
await scheduleCheckinForInvoice(invoice)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin')
|
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
||||||
|
|||||||
@ -1,24 +1,16 @@
|
|||||||
import Invoice from '#models/invoice'
|
import Invoice from '#models/invoice'
|
||||||
import Plan from '#models/plan'
|
import Plan from '#models/plan'
|
||||||
|
import RelanceTask from '#models/relance_task'
|
||||||
import InvoiceTransformer from '#transformers/invoice_transformer'
|
import InvoiceTransformer from '#transformers/invoice_transformer'
|
||||||
import {
|
import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice'
|
||||||
createInvoiceValidator,
|
|
||||||
listInvoicesValidator,
|
|
||||||
} from '#validators/invoice'
|
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
import { Exception } from '@adonisjs/core/exceptions'
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { resolveClient } from '#services/resolve_client'
|
import { resolveClient } from '#services/resolve_client'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import {
|
import { cancelFutureRelances } from '#services/relance_scheduler'
|
||||||
scheduleRelancesForInvoice,
|
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
|
||||||
cancelFutureRelances,
|
|
||||||
} from '#services/relance_scheduler'
|
|
||||||
import {
|
|
||||||
scheduleCheckinForInvoice,
|
|
||||||
cancelCheckinForInvoice,
|
|
||||||
} from '#services/checkin_scheduler'
|
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@ -53,7 +45,10 @@ function serializeInvoice(i: Invoice) {
|
|||||||
* - étape actuelle (la prochaine future) : 'current'
|
* - étape actuelle (la prochaine future) : 'current'
|
||||||
* - étapes futures : 'future'
|
* - étapes futures : 'future'
|
||||||
*/
|
*/
|
||||||
function buildTimeline(invoice: Invoice): Array<{
|
function buildTimeline(
|
||||||
|
invoice: Invoice,
|
||||||
|
relanceTasks: RelanceTask[] = []
|
||||||
|
): Array<{
|
||||||
id: string
|
id: string
|
||||||
state: 'past' | 'current' | 'future'
|
state: 'past' | 'current' | 'future'
|
||||||
when: string
|
when: string
|
||||||
@ -73,35 +68,43 @@ function buildTimeline(invoice: Invoice): Array<{
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (
|
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
|
||||||
invoice.plan?.steps?.length &&
|
|
||||||
invoice.status !== 'paid' &&
|
|
||||||
invoice.status !== 'cancelled'
|
|
||||||
) {
|
|
||||||
const dueMs = invoice.dueDate.toMillis()
|
const dueMs = invoice.dueDate.toMillis()
|
||||||
const nowMs = DateTime.now().toMillis()
|
const nowMs = DateTime.now().toMillis()
|
||||||
|
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
|
||||||
let currentSet = false
|
let currentSet = false
|
||||||
|
|
||||||
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
|
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
|
||||||
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
|
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
|
||||||
const stepDate = DateTime.fromMillis(sendMs)
|
const task = taskByStepId.get(step.id)
|
||||||
|
const stepDate = task?.sentAt ?? task?.sendAt ?? DateTime.fromMillis(sendMs)
|
||||||
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
|
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
|
||||||
|
|
||||||
let state: 'past' | 'current' | 'future'
|
let state: 'past' | 'current' | 'future'
|
||||||
if (sendMs < nowMs) state = 'past'
|
if (task?.status === 'sent') state = 'past'
|
||||||
else if (!currentSet) {
|
else if (task?.status === 'scheduled' && task.sendAt.toMillis() < nowMs) state = 'current'
|
||||||
|
else if (!task && invoice.status === 'pending' && !currentSet) {
|
||||||
|
state = 'current'
|
||||||
|
currentSet = true
|
||||||
|
} else if (!currentSet) {
|
||||||
state = 'current'
|
state = 'current'
|
||||||
currentSet = true
|
currentSet = true
|
||||||
} else state = 'future'
|
} else state = 'future'
|
||||||
|
|
||||||
|
const subject = step.subject.replace('{{numero}}', invoice.numero)
|
||||||
|
const what = task
|
||||||
|
? task.status === 'sent'
|
||||||
|
? `Email envoyé · "${subject}"`
|
||||||
|
: `Email programmé · "${subject}"`
|
||||||
|
: invoice.status === 'pending'
|
||||||
|
? `À programmer après check-in · "${subject}"`
|
||||||
|
: `Relance non programmée · "${subject}"`
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
id: `${invoice.id}__step_${step.order}`,
|
id: `${invoice.id}__step_${step.order}`,
|
||||||
state,
|
state,
|
||||||
when: `${formatShortDate(stepDate)} · ${labelStep}`,
|
when: `${formatShortDate(stepDate)} · ${labelStep}`,
|
||||||
what:
|
what,
|
||||||
state === 'past'
|
|
||||||
? `Email envoyé · "${step.subject.replace('{{numero}}', invoice.numero)}"`
|
|
||||||
: `Email programmé · "${step.subject.replace('{{numero}}', invoice.numero)}"`,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -223,6 +226,9 @@ export default class InvoicesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = serializeInvoice(invoice)
|
const data = serializeInvoice(invoice)
|
||||||
|
const relanceTasks = await RelanceTask.query()
|
||||||
|
.where('invoice_id', invoice.id)
|
||||||
|
.whereNot('status', 'cancelled')
|
||||||
return response.json({
|
return response.json({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
@ -251,7 +257,7 @@ export default class InvoicesController {
|
|||||||
requiresManualValidation: s.requiresManualValidation,
|
requiresManualValidation: s.requiresManualValidation,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
timeline: buildTimeline(invoice),
|
timeline: buildTimeline(invoice, relanceTasks),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -305,18 +311,12 @@ export default class InvoicesController {
|
|||||||
await invoice.load('client')
|
await invoice.load('client')
|
||||||
await invoice.load('plan')
|
await invoice.load('plan')
|
||||||
|
|
||||||
// Programme les relances BullMQ si la facture a un plan + le check-in
|
// Programme uniquement le check-in (envoyé à dueDate). Les relances
|
||||||
// (envoyé pile à dueDate). Hors tx — Redis ne participe pas aux
|
// client ne partent qu'après confirmation "toujours en attente".
|
||||||
// garanties DB. On ne fait pas planter la requête HTTP si Redis est
|
|
||||||
// down : la facture est créée, l'utilisateur peut re-déclencher la
|
|
||||||
// programmation plus tard.
|
|
||||||
try {
|
try {
|
||||||
if (invoice.planId) {
|
|
||||||
await scheduleRelancesForInvoice(invoice)
|
|
||||||
}
|
|
||||||
await scheduleCheckinForInvoice(invoice)
|
await scheduleCheckinForInvoice(invoice)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule relances/checkin')
|
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.status(201).json({ data: serializeInvoice(invoice) })
|
return response.status(201).json({ data: serializeInvoice(invoice) })
|
||||||
@ -353,10 +353,7 @@ export default class InvoicesController {
|
|||||||
await invoice.save()
|
await invoice.save()
|
||||||
|
|
||||||
// Bump du compteur agrégé sur l'organisation
|
// Bump du compteur agrégé sur l'organisation
|
||||||
await trx
|
await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
|
||||||
.from('organizations')
|
|
||||||
.where('id', organizationId)
|
|
||||||
.increment('rubis_count', 1)
|
|
||||||
|
|
||||||
// Journal d'activité (cf. dashboard activity feed).
|
// Journal d'activité (cf. dashboard activity feed).
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
|
|||||||
@ -8,6 +8,13 @@ const OCR_MODEL = 'mistral-ocr-latest'
|
|||||||
// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict).
|
// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict).
|
||||||
const EXTRACTION_MODEL = 'mistral-large-latest'
|
const EXTRACTION_MODEL = 'mistral-large-latest'
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B.
|
const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B.
|
||||||
Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un
|
Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un
|
||||||
JSON strict avec les champs demandés.
|
JSON strict avec les champs demandés.
|
||||||
@ -51,14 +58,15 @@ export class MistralOcrProvider implements OcrProvider {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Télécharge le PDF depuis Drive (MinIO en dev) puis encode en base64.
|
// 1. Télécharge le fichier depuis Drive (MinIO en dev) puis encode en base64.
|
||||||
const buffer = await this.downloadAsBuffer(input.storageKey)
|
const buffer = await this.downloadAsBuffer(input.storageKey)
|
||||||
const dataUri = `data:application/pdf;base64,${buffer.toString('base64')}`
|
const mimeType = this.mimeTypeFromFilename(input.filename)
|
||||||
|
const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
|
|
||||||
// 2. OCR → markdown
|
// 2. OCR → markdown
|
||||||
const ocrJson = await this.postJson('/ocr', {
|
const ocrJson = await this.postJson('/ocr', {
|
||||||
model: OCR_MODEL,
|
model: OCR_MODEL,
|
||||||
document: { type: 'document_url', document_url: dataUri },
|
document: this.documentPayload(dataUri, mimeType),
|
||||||
})
|
})
|
||||||
const markdown = (ocrJson?.pages ?? [])
|
const markdown = (ocrJson?.pages ?? [])
|
||||||
.map((p: { markdown?: string }) => p.markdown ?? '')
|
.map((p: { markdown?: string }) => p.markdown ?? '')
|
||||||
@ -93,6 +101,27 @@ export class MistralOcrProvider implements OcrProvider {
|
|||||||
return Buffer.from(arr)
|
return Buffer.from(arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mimeTypeFromFilename(filename: string): string {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
const mimeType = MIME_BY_EXT[ext]
|
||||||
|
if (!mimeType) {
|
||||||
|
throw new Error(`Format OCR non supporté pour "${filename}"`)
|
||||||
|
}
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
private documentPayload(
|
||||||
|
dataUri: string,
|
||||||
|
mimeType: string
|
||||||
|
):
|
||||||
|
| { type: 'document_url'; document_url: string }
|
||||||
|
| { type: 'image_url'; image_url: string } {
|
||||||
|
if (mimeType === 'application/pdf') {
|
||||||
|
return { type: 'document_url', document_url: dataUri }
|
||||||
|
}
|
||||||
|
return { type: 'image_url', image_url: dataUri }
|
||||||
|
}
|
||||||
|
|
||||||
private async postJson(path: string, body: unknown): Promise<any> {
|
private async postJson(path: string, body: unknown): Promise<any> {
|
||||||
const res = await fetch(`${MISTRAL_API}${path}`, {
|
const res = await fetch(`${MISTRAL_API}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import RelanceTask from '#models/relance_task'
|
import RelanceTask from '#models/relance_task'
|
||||||
import Plan from '#models/plan'
|
import Plan from '#models/plan'
|
||||||
import Invoice from '#models/invoice'
|
import type Invoice from '#models/invoice'
|
||||||
import { getQueue } from '#services/queue'
|
import { getQueue } from '#services/queue'
|
||||||
import app from '@adonisjs/core/services/app'
|
import app from '@adonisjs/core/services/app'
|
||||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||||
@ -25,10 +25,10 @@ function shouldEnqueue(): boolean {
|
|||||||
* - Crée une RelanceTask `scheduled`
|
* - Crée une RelanceTask `scheduled`
|
||||||
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
|
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
|
||||||
*
|
*
|
||||||
* Si sendAt est dans le passé (cas : facture importée avec une dueDate
|
* Si une facture est déjà en retard quand l'utilisateur confirme "toujours
|
||||||
* ancienne), on programme quand même la task pour `now + 1 min` — l'user
|
* en attente", on n'envoie pas toutes les étapes passées d'un coup :
|
||||||
* est probablement en train de "rattraper" un retard, l'envoi immédiat
|
* la première étape éligible part à `now + 1 min`, puis les suivantes
|
||||||
* est cohérent.
|
* gardent l'écart du plan à partir de ce nouveau départ.
|
||||||
*
|
*
|
||||||
* Idempotent par invoice.id : si des tasks `scheduled` existent déjà
|
* Idempotent par invoice.id : si des tasks `scheduled` existent déjà
|
||||||
* pour cette facture, on les annule avant de re-programmer (cas où on
|
* 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é.
|
// Ignore — le job peut déjà être consommé.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
t.useTransaction(trx ?? null as never)
|
t.useTransaction(trx ?? (null as never))
|
||||||
t.status = 'cancelled'
|
t.status = 'cancelled'
|
||||||
await t.save()
|
await t.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = DateTime.now()
|
const now = DateTime.now()
|
||||||
const created: RelanceTask[] = []
|
const created: RelanceTask[] = []
|
||||||
|
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
|
||||||
|
const firstOverdueStep = steps.find(
|
||||||
|
(step) => invoice.dueDate.plus({ days: step.offsetDays }) < now
|
||||||
|
)
|
||||||
|
const catchUpAnchor = firstOverdueStep
|
||||||
|
? {
|
||||||
|
offsetDays: firstOverdueStep.offsetDays,
|
||||||
|
sendAt: now.plus({ minutes: 1 }),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
for (const step of plan.steps) {
|
for (const step of steps) {
|
||||||
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
|
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
|
||||||
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw
|
const sendAt =
|
||||||
|
catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays
|
||||||
|
? catchUpAnchor.sendAt.plus({
|
||||||
|
days: step.offsetDays - catchUpAnchor.offsetDays,
|
||||||
|
})
|
||||||
|
: sendAtRaw
|
||||||
|
|
||||||
const task = await RelanceTask.create(
|
const task = await RelanceTask.create(
|
||||||
{
|
{
|
||||||
@ -128,7 +143,7 @@ export async function cancelFutureRelances(
|
|||||||
if (t.queueJobId && queue) {
|
if (t.queueJobId && queue) {
|
||||||
await queue.remove(t.queueJobId).catch(() => {})
|
await queue.remove(t.queueJobId).catch(() => {})
|
||||||
}
|
}
|
||||||
t.useTransaction(trx ?? null as never)
|
t.useTransaction(trx ?? (null as never))
|
||||||
t.status = 'cancelled'
|
t.status = 'cancelled'
|
||||||
await t.save()
|
await t.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { test } from '@japa/runner'
|
import { test } from '@japa/runner'
|
||||||
import testUtils from '@adonisjs/core/services/test_utils'
|
import testUtils from '@adonisjs/core/services/test_utils'
|
||||||
import RelanceTask from '#models/relance_task'
|
import RelanceTask from '#models/relance_task'
|
||||||
|
import CheckinTask from '#models/checkin_task'
|
||||||
|
import Invoice from '#models/invoice'
|
||||||
import Organization from '#models/organization'
|
import Organization from '#models/organization'
|
||||||
|
import { hashCheckinToken } from '#services/checkin_token'
|
||||||
import { createTestUser, createTwoOrgs } from '../helpers/auth.js'
|
import { createTestUser, createTwoOrgs } from '../helpers/auth.js'
|
||||||
import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js'
|
import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
|
||||||
type InvoiceShape = {
|
type InvoiceShape = {
|
||||||
id: string
|
id: string
|
||||||
@ -35,24 +39,18 @@ async function getStandardPlan(client: any, headers: Record<string, string>) {
|
|||||||
test.group('Invoices — POST /invoices', (group) => {
|
test.group('Invoices — POST /invoices', (group) => {
|
||||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({
|
test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({ client, assert }) => {
|
||||||
client,
|
|
||||||
assert,
|
|
||||||
}) => {
|
|
||||||
const { bearer } = await createTestUser()
|
const { bearer } = await createTestUser()
|
||||||
const c = await createClient(client, bearer, 'Boulangerie Spec')
|
const c = await createClient(client, bearer, 'Boulangerie Spec')
|
||||||
|
|
||||||
const r = await client
|
const r = await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientId: c.id,
|
||||||
.headers(bearer)
|
clientName: c.name,
|
||||||
.json({
|
numero: 'F-2026-T01',
|
||||||
clientId: c.id,
|
amountTtcCents: 124000,
|
||||||
clientName: c.name,
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-T01',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 124000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
r.assertStatus(201)
|
r.assertStatus(201)
|
||||||
const inv = body<ApiOk<InvoiceShape>>(r).data
|
const inv = body<ApiOk<InvoiceShape>>(r).data
|
||||||
@ -60,23 +58,17 @@ test.group('Invoices — POST /invoices', (group) => {
|
|||||||
assert.equal(inv.status, 'pending')
|
assert.equal(inv.status, 'pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('crée à la volée un client si nom non matché + email fourni', async ({
|
test('crée à la volée un client si nom non matché + email fourni', async ({ client, assert }) => {
|
||||||
client,
|
|
||||||
assert,
|
|
||||||
}) => {
|
|
||||||
const { bearer, accessToken } = await createTestUser()
|
const { bearer, accessToken } = await createTestUser()
|
||||||
|
|
||||||
const r = await client
|
const r = await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientName: 'Nouveau Client',
|
||||||
.headers(bearer)
|
clientEmail: 'nouveau@spec.test',
|
||||||
.json({
|
numero: 'F-2026-T02',
|
||||||
clientName: 'Nouveau Client',
|
amountTtcCents: 50000,
|
||||||
clientEmail: 'nouveau@spec.test',
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-T02',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 50000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
r.assertStatus(201)
|
r.assertStatus(201)
|
||||||
|
|
||||||
@ -95,46 +87,91 @@ test.group('Invoices — POST /invoices', (group) => {
|
|||||||
}) => {
|
}) => {
|
||||||
const { bearer } = await createTestUser()
|
const { bearer } = await createTestUser()
|
||||||
|
|
||||||
const r = await client
|
const r = await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientName: 'Sans Email',
|
||||||
.headers(bearer)
|
numero: 'F-2026-T03',
|
||||||
.json({
|
amountTtcCents: 50000,
|
||||||
clientName: 'Sans Email',
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-T03',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 50000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
r.assertStatus(422)
|
r.assertStatus(422)
|
||||||
const errors = body<{ errors: Array<{ code: string }> }>(r).errors
|
const errors = body<{ errors: Array<{ code: string }> }>(r).errors
|
||||||
assert.equal(errors[0].code, 'client_email_required')
|
assert.equal(errors[0].code, 'client_email_required')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('schedule des RelanceTasks si planId est fourni', async ({ client, assert }) => {
|
test('schedule uniquement le check-in si planId est fourni', async ({ client, assert }) => {
|
||||||
const { bearer } = await createTestUser()
|
const { bearer } = await createTestUser()
|
||||||
const c = await createClient(client, bearer, 'Avec Plan')
|
const c = await createClient(client, bearer, 'Avec Plan')
|
||||||
const plan = await getStandardPlan(client, bearer)
|
const plan = await getStandardPlan(client, bearer)
|
||||||
|
|
||||||
const r = await client
|
const r = await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
|
clientId: c.id,
|
||||||
|
clientName: c.name,
|
||||||
|
planId: plan.id,
|
||||||
|
numero: 'F-2026-T04',
|
||||||
|
amountTtcCents: 50000,
|
||||||
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
r.assertStatus(201)
|
||||||
|
const inv = body<ApiOk<InvoiceShape>>(r).data
|
||||||
|
const tasks = await RelanceTask.query().where('invoice_id', inv.id)
|
||||||
|
const checkins = await CheckinTask.query().where('invoice_id', inv.id)
|
||||||
|
assert.lengthOf(tasks, 0)
|
||||||
|
assert.lengthOf(checkins, 1)
|
||||||
|
assert.equal(checkins[0].status, 'scheduled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clic check-in pending programme les relances sans rafale passée', async ({
|
||||||
|
client,
|
||||||
|
assert,
|
||||||
|
}) => {
|
||||||
|
const { bearer, org } = await createTestUser()
|
||||||
|
const c = await createClient(client, bearer, 'Checkin Pending')
|
||||||
|
const plan = await getStandardPlan(client, bearer)
|
||||||
|
const dueDate = DateTime.now().minus({ days: 20 }).set({ hour: 9, minute: 0, second: 0 })
|
||||||
|
|
||||||
|
const created = await client
|
||||||
.post('/api/v1/invoices')
|
.post('/api/v1/invoices')
|
||||||
.headers(bearer)
|
.headers(bearer)
|
||||||
.json({
|
.json({
|
||||||
clientId: c.id,
|
clientId: c.id,
|
||||||
clientName: c.name,
|
clientName: c.name,
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
numero: 'F-2026-T04',
|
numero: 'F-2026-CHECKIN',
|
||||||
amountTtcCents: 50000,
|
amountTtcCents: 50000,
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
issueDate: dueDate.minus({ days: 15 }).toISO(),
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
dueDate: dueDate.toISO(),
|
||||||
})
|
})
|
||||||
|
|
||||||
r.assertStatus(201)
|
created.assertStatus(201)
|
||||||
const inv = body<ApiOk<InvoiceShape>>(r).data
|
const inv = body<ApiOk<InvoiceShape>>(created).data
|
||||||
const tasks = await RelanceTask.query().where('invoice_id', inv.id)
|
const plain = 'pending-token-spec'
|
||||||
// Le plan standard-30j a 3 steps
|
const checkin = await CheckinTask.query().where('invoice_id', inv.id).firstOrFail()
|
||||||
|
checkin.tokenHash = hashCheckinToken(plain)
|
||||||
|
checkin.status = 'sent'
|
||||||
|
checkin.sentAt = DateTime.now()
|
||||||
|
await checkin.save()
|
||||||
|
|
||||||
|
const beforeTasks = await RelanceTask.query().where('invoice_id', inv.id)
|
||||||
|
assert.lengthOf(beforeTasks, 0)
|
||||||
|
|
||||||
|
const pending = await client.get(`/api/v1/checkin/${plain}/pending`)
|
||||||
|
pending.assertStatus(200)
|
||||||
|
|
||||||
|
const invoice = await Invoice.findOrFail(inv.id)
|
||||||
|
const tasks = await RelanceTask.query()
|
||||||
|
.where('invoice_id', inv.id)
|
||||||
|
.preload('planStep')
|
||||||
|
.orderBy('send_at', 'asc')
|
||||||
|
|
||||||
|
assert.equal(invoice.organizationId, org.id)
|
||||||
assert.lengthOf(tasks, 3)
|
assert.lengthOf(tasks, 3)
|
||||||
for (const t of tasks) assert.equal(t.status, 'scheduled')
|
for (const t of tasks) assert.equal(t.status, 'scheduled')
|
||||||
|
assert.isAtMost(Math.abs(tasks[0].sendAt.diffNow('minutes').minutes), 2)
|
||||||
|
assert.isAbove(tasks[1].sendAt.toMillis(), tasks[0].sendAt.toMillis())
|
||||||
})
|
})
|
||||||
|
|
||||||
test('numéro unique par org (422 duplicate)', async ({ client }) => {
|
test('numéro unique par org (422 duplicate)', async ({ client }) => {
|
||||||
@ -162,17 +199,14 @@ test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
|
|||||||
test('idempotent : 2e appel ne re-bumpe pas rubisEarned', async ({ client, assert }) => {
|
test('idempotent : 2e appel ne re-bumpe pas rubisEarned', async ({ client, assert }) => {
|
||||||
const { bearer, org } = await createTestUser()
|
const { bearer, org } = await createTestUser()
|
||||||
const c = await createClient(client, bearer, 'Idem')
|
const c = await createClient(client, bearer, 'Idem')
|
||||||
const created = await client
|
const created = await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientId: c.id,
|
||||||
.headers(bearer)
|
clientName: c.name,
|
||||||
.json({
|
numero: 'F-2026-T10',
|
||||||
clientId: c.id,
|
amountTtcCents: 100000,
|
||||||
clientName: c.name,
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-T10',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 100000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
const id = body<ApiOk<InvoiceShape>>(created).data.id
|
const id = body<ApiOk<InvoiceShape>>(created).data.id
|
||||||
|
|
||||||
const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer)
|
const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer)
|
||||||
@ -190,25 +224,32 @@ test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
|
|||||||
assert.equal(orgFresh.rubisCount, 1)
|
assert.equal(orgFresh.rubisCount, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("annule les RelanceTasks scheduled de la facture", async ({ client, assert }) => {
|
test('annule les RelanceTasks scheduled de la facture', async ({ client, assert }) => {
|
||||||
const { bearer } = await createTestUser()
|
const { bearer } = await createTestUser()
|
||||||
const c = await createClient(client, bearer, 'Cancel Tasks')
|
const c = await createClient(client, bearer, 'Cancel Tasks')
|
||||||
const plan = await getStandardPlan(client, bearer)
|
const plan = await getStandardPlan(client, bearer)
|
||||||
|
|
||||||
const created = await client
|
const created = await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientId: c.id,
|
||||||
.headers(bearer)
|
clientName: c.name,
|
||||||
.json({
|
planId: plan.id,
|
||||||
clientId: c.id,
|
numero: 'F-2026-T11',
|
||||||
clientName: c.name,
|
amountTtcCents: 100000,
|
||||||
planId: plan.id,
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-T11',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 100000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
const id = body<ApiOk<InvoiceShape>>(created).data.id
|
const id = body<ApiOk<InvoiceShape>>(created).data.id
|
||||||
|
|
||||||
|
const plain = 'cancel-token-spec'
|
||||||
|
const checkin = await CheckinTask.query().where('invoice_id', id).firstOrFail()
|
||||||
|
checkin.tokenHash = hashCheckinToken(plain)
|
||||||
|
checkin.status = 'sent'
|
||||||
|
checkin.sentAt = DateTime.now()
|
||||||
|
await checkin.save()
|
||||||
|
|
||||||
|
const pending = await client.get(`/api/v1/checkin/${plain}/pending`)
|
||||||
|
pending.assertStatus(200)
|
||||||
|
|
||||||
const beforeTasks = await RelanceTask.query().where('invoice_id', id)
|
const beforeTasks = await RelanceTask.query().where('invoice_id', id)
|
||||||
assert.lengthOf(beforeTasks, 3)
|
assert.lengthOf(beforeTasks, 3)
|
||||||
|
|
||||||
@ -218,22 +259,17 @@ test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
|
|||||||
for (const t of afterTasks) assert.equal(t.status, 'cancelled')
|
for (const t of afterTasks) assert.equal(t.status, 'cancelled')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({
|
test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({ client }) => {
|
||||||
client,
|
|
||||||
}) => {
|
|
||||||
const { a, b } = await createTwoOrgs()
|
const { a, b } = await createTwoOrgs()
|
||||||
const c = await createClient(client, a.bearer, 'Of A')
|
const c = await createClient(client, a.bearer, 'Of A')
|
||||||
const created = await client
|
const created = await client.post('/api/v1/invoices').headers(a.bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientId: c.id,
|
||||||
.headers(a.bearer)
|
clientName: c.name,
|
||||||
.json({
|
numero: 'F-2026-XO1',
|
||||||
clientId: c.id,
|
amountTtcCents: 50000,
|
||||||
clientName: c.name,
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-XO1',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 50000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
const id = body<ApiOk<InvoiceShape>>(created).data.id
|
const id = body<ApiOk<InvoiceShape>>(created).data.id
|
||||||
|
|
||||||
const r = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(b.bearer)
|
const r = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(b.bearer)
|
||||||
@ -273,17 +309,14 @@ test.group('Invoices — GET /invoices', (group) => {
|
|||||||
const { bearer } = await createTestUser()
|
const { bearer } = await createTestUser()
|
||||||
const c = await createClient(client, bearer, 'Counts Spec')
|
const c = await createClient(client, bearer, 'Counts Spec')
|
||||||
|
|
||||||
await client
|
await client.post('/api/v1/invoices').headers(bearer).json({
|
||||||
.post('/api/v1/invoices')
|
clientId: c.id,
|
||||||
.headers(bearer)
|
clientName: c.name,
|
||||||
.json({
|
numero: 'F-2026-C01',
|
||||||
clientId: c.id,
|
amountTtcCents: 10000,
|
||||||
clientName: c.name,
|
issueDate: '2026-04-01T09:00:00.000Z',
|
||||||
numero: 'F-2026-C01',
|
dueDate: '2026-05-01T09:00:00.000Z',
|
||||||
amountTtcCents: 10000,
|
})
|
||||||
issueDate: '2026-04-01T09:00:00.000Z',
|
|
||||||
dueDate: '2026-05-01T09:00:00.000Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
const r = await client.get('/api/v1/invoices/counts').headers(bearer)
|
const r = await client.get('/api/v1/invoices/counts').headers(bearer)
|
||||||
r.assertStatus(200)
|
r.assertStatus(200)
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
VITE_API_URL=http://localhost:3333
|
VITE_API_URL=http://localhost:3333
|
||||||
VITE_PUBLIC_LANDING_URL=http://localhost:8080
|
VITE_PUBLIC_LANDING_URL=http://localhost:8080
|
||||||
VITE_USE_MOCKS=true
|
VITE_USE_MOCKS=false
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
# URL de l'API AdonisJS. En dev local, MSW intercepte les requêtes —
|
# URL de l'API AdonisJS.
|
||||||
# cette URL n'est utilisée que comme base path symbolique tant que le backend
|
|
||||||
# n'est pas branché.
|
|
||||||
VITE_API_URL=http://localhost:3333
|
VITE_API_URL=http://localhost:3333
|
||||||
|
|
||||||
# URL de la landing publique (lien retour depuis l'app)
|
# URL de la landing publique (lien retour depuis l'app)
|
||||||
VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr
|
VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr
|
||||||
|
|
||||||
# Active MSW pour mocker l'API. Mettre à "false" pour taper le vrai backend.
|
# Active MSW pour mocker l'API. Laisser à "false" pour taper le vrai backend.
|
||||||
VITE_USE_MOCKS=true
|
VITE_USE_MOCKS=false
|
||||||
|
|||||||
@ -1,22 +1,19 @@
|
|||||||
|
import type { AuthSession } from "@rubis/shared";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { authStore } from "./auth";
|
import { authStore } from "./auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client HTTP minimal — placeholder en attendant que le client Tuyau
|
* Client HTTP — façade unique pour l'API REST. Centralise :
|
||||||
* soit branché contre le code Adonis (cf. /docs/tech/frontend.md §6).
|
* - Header Authorization (Bearer token depuis authStore)
|
||||||
|
* - Cookie refresh httpOnly (`credentials: 'include'`)
|
||||||
|
* - Silent refresh sur 401 (rotation du token + retry une fois)
|
||||||
|
* - Gestion d'erreur uniforme via la classe ApiError
|
||||||
*
|
*
|
||||||
* Tant que MSW intercepte ou que l'API n'est pas prête, on tape via fetch
|
* Le contrat de réponse Adonis est `{ data: ... }` (cf. backend.md §6).
|
||||||
* sur baseUrl/api/v1/... et on sérialise/désérialise nous-mêmes.
|
* Les erreurs sont `{ errors: [{ code, message, field? }] }`.
|
||||||
*
|
*
|
||||||
* Une fois Tuyau opérationnel :
|
* En dev avec VITE_USE_MOCKS=true, MSW intercepte transparently — ce
|
||||||
* import { createTuyau } from "@tuyau/client"
|
* fichier fonctionne pareil dans les deux modes.
|
||||||
* import { api } from "@rubis/api/registry"
|
|
||||||
* export const tuyau = createTuyau({ api, baseUrl: env.VITE_API_URL, ... })
|
|
||||||
*
|
|
||||||
* On gardera ce fichier comme façade pour pouvoir centraliser :
|
|
||||||
* - l'auth header
|
|
||||||
* - le retry sur 401 (silent refresh)
|
|
||||||
* - la gestion d'erreur uniforme
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@ -39,12 +36,45 @@ type RequestOptions = {
|
|||||||
anonymous?: boolean;
|
anonymous?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
/**
|
||||||
|
* Refresh en cours partagé entre toutes les requêtes 401 simultanées :
|
||||||
|
* si N requêtes reviennent 401 en même temps, on n'envoie qu'un seul
|
||||||
|
* /auth/refresh et toutes attendent le même résultat.
|
||||||
|
*/
|
||||||
|
let pendingRefresh: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function performRefresh(): Promise<void> {
|
||||||
|
if (!pendingRefresh) {
|
||||||
|
pendingRefresh = (async () => {
|
||||||
|
try {
|
||||||
|
const session = await rawRequest<AuthSession>("/api/v1/auth/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
anonymous: true,
|
||||||
|
});
|
||||||
|
authStore.setSession(session.accessToken, session.user);
|
||||||
|
} catch (err) {
|
||||||
|
authStore.clear();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
pendingRefresh = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return pendingRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bas niveau : fait la requête sans tenter de silent refresh.
|
||||||
|
* Utilisé par performRefresh elle-même (sinon boucle infinie sur
|
||||||
|
* /auth/refresh qui revient 401).
|
||||||
|
*/
|
||||||
|
async function rawRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { method = "GET", body, signal, anonymous = false } = options;
|
const { method = "GET", body, signal, anonymous = false } = options;
|
||||||
|
const isFormData = body instanceof FormData;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
};
|
};
|
||||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
if (body !== undefined && !isFormData) headers["Content-Type"] = "application/json";
|
||||||
if (!anonymous && authStore.token) {
|
if (!anonymous && authStore.token) {
|
||||||
headers.Authorization = `Bearer ${authStore.token}`;
|
headers.Authorization = `Bearer ${authStore.token}`;
|
||||||
}
|
}
|
||||||
@ -55,7 +85,12 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body:
|
||||||
|
body === undefined
|
||||||
|
? undefined
|
||||||
|
: isFormData
|
||||||
|
? body
|
||||||
|
: JSON.stringify(body),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,17 +121,64 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convention de réponse Adonis : { data: ..., meta?: ... }
|
// Convention de réponse Adonis : { data: ..., meta?: ... }. On extrait
|
||||||
|
// `data` quand il est présent (contrat documenté), sinon on renvoie
|
||||||
|
// le body tel quel (cas rare : endpoint qui retourne un objet plat).
|
||||||
return (json?.data ?? json) as T;
|
return (json?.data ?? json) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Niveau public : ajoute le silent refresh sur 401.
|
||||||
|
*
|
||||||
|
* Si une requête authentifiée revient 401 :
|
||||||
|
* 1. On lance (ou attend) un seul /auth/refresh
|
||||||
|
* 2. Si le refresh réussit, on retry la requête originale avec le
|
||||||
|
* nouveau token
|
||||||
|
* 3. Si le refresh échoue, l'authStore est cleared (le router guard
|
||||||
|
* redirige vers /login) et on propage le 401 original
|
||||||
|
*
|
||||||
|
* Les requêtes anonymes (signup, login, refresh lui-même) ne tentent
|
||||||
|
* pas de refresh — elles n'ont pas de token à rafraîchir.
|
||||||
|
*/
|
||||||
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await rawRequest<T>(path, options);
|
||||||
|
} catch (err) {
|
||||||
|
const isAuthEndpoint = path.startsWith("/api/v1/auth/");
|
||||||
|
const shouldRefresh =
|
||||||
|
err instanceof ApiError &&
|
||||||
|
err.status === 401 &&
|
||||||
|
!options.anonymous &&
|
||||||
|
!isAuthEndpoint &&
|
||||||
|
authStore.token !== null;
|
||||||
|
|
||||||
|
if (!shouldRefresh) throw err;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performRefresh();
|
||||||
|
} catch {
|
||||||
|
// refresh KO → on propage le 401 original (le store est déjà cleared)
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry une seule fois avec le nouveau token.
|
||||||
|
return rawRequest<T>(path, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||||
request<T>(path, { ...options, method: "GET" }),
|
request<T>(path, { ...options, method: "GET" }),
|
||||||
post: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
post: <T>(
|
||||||
request<T>(path, { ...options, method: "POST", body }),
|
path: string,
|
||||||
patch: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
body?: unknown,
|
||||||
request<T>(path, { ...options, method: "PATCH", body }),
|
options?: Omit<RequestOptions, "method" | "body">,
|
||||||
|
): Promise<T> => request<T>(path, { ...options, method: "POST", body }),
|
||||||
|
patch: <T>(
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
options?: Omit<RequestOptions, "method" | "body">,
|
||||||
|
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
|
||||||
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||||
request<T>(path, { ...options, method: "DELETE" }),
|
request<T>(path, { ...options, method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|||||||
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),
|
filenames: z.array(z.string().min(1)).min(1).max(20),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function filenamesFromUploadRequest(request: Request): Promise<string[]> {
|
||||||
|
const contentType = request.headers.get("content-type") ?? "";
|
||||||
|
if (contentType.startsWith("multipart/")) {
|
||||||
|
return request
|
||||||
|
.formData()
|
||||||
|
.then((formData) =>
|
||||||
|
formData
|
||||||
|
.getAll("files")
|
||||||
|
.filter((file): file is File => file instanceof File)
|
||||||
|
.map((file) => file.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await request.json();
|
||||||
|
const parsed = uploadSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw parsed.error;
|
||||||
|
}
|
||||||
|
return parsed.data.filenames;
|
||||||
|
}
|
||||||
|
|
||||||
const draftFieldsSchema = z.object({
|
const draftFieldsSchema = z.object({
|
||||||
clientId: z.string().nullable(),
|
clientId: z.string().nullable(),
|
||||||
clientName: z.string().min(1).max(120),
|
clientName: z.string().min(1).max(120),
|
||||||
@ -420,12 +441,14 @@ export const invoiceHandlers = [
|
|||||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
if (!orgId) return unauthenticated();
|
if (!orgId) return unauthenticated();
|
||||||
|
|
||||||
const json = await request.json();
|
let filenames: string[];
|
||||||
const parsed = uploadSchema.safeParse(json);
|
try {
|
||||||
if (!parsed.success) {
|
filenames = await filenamesFromUploadRequest(request);
|
||||||
|
uploadSchema.parse({ filenames });
|
||||||
|
} catch (err) {
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{
|
{
|
||||||
errors: parsed.error.issues.map((i) => ({
|
errors: (err instanceof z.ZodError ? err.issues : []).map((i) => ({
|
||||||
code: "validation_failed",
|
code: "validation_failed",
|
||||||
message: i.message,
|
message: i.message,
|
||||||
})),
|
})),
|
||||||
@ -438,7 +461,7 @@ export const invoiceHandlers = [
|
|||||||
const plans = mockDb.listPlansForOrg(orgId);
|
const plans = mockDb.listPlansForOrg(orgId);
|
||||||
const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null;
|
const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null;
|
||||||
|
|
||||||
const drafts = parsed.data.filenames.map((filename) => {
|
const drafts = filenames.map((filename) => {
|
||||||
const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId);
|
const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId);
|
||||||
return { filename, extracted, confidence };
|
return { filename, extracted, confidence };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,11 +18,7 @@ import {
|
|||||||
} from "@/components/factures/InvoiceTable";
|
} from "@/components/factures/InvoiceTable";
|
||||||
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
|
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
|
||||||
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
||||||
|
import { uploadInvoiceFiles } from "@/lib/invoices";
|
||||||
type ImportBatchResponse = {
|
|
||||||
id: string;
|
|
||||||
drafts: Array<{ id: string; filename: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
|
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
|
||||||
const FILTER_KEYS = [
|
const FILTER_KEYS = [
|
||||||
@ -64,10 +60,7 @@ export const Route = createFileRoute("/_app/factures")({
|
|||||||
function useUploadInvoices() {
|
function useUploadInvoices() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (files: File[]) =>
|
mutationFn: uploadInvoiceFiles,
|
||||||
api.post<ImportBatchResponse>("/api/v1/invoices/upload", {
|
|
||||||
filenames: files.map((f) => f.name),
|
|
||||||
}),
|
|
||||||
onSuccess: (batch) => {
|
onSuccess: (batch) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${
|
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${
|
||||||
|
|||||||
@ -3,16 +3,11 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { ArrowLeft, FilePlus } from "lucide-react";
|
import { ArrowLeft, FilePlus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
import { Dropzone } from "@/components/factures/Dropzone";
|
import { Dropzone } from "@/components/factures/Dropzone";
|
||||||
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
||||||
|
import { uploadInvoiceFiles } from "@/lib/invoices";
|
||||||
type ImportBatchResponse = {
|
|
||||||
id: string;
|
|
||||||
drafts: Array<{ id: string; filename: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/factures_/import")({
|
export const Route = createFileRoute("/_app/factures_/import")({
|
||||||
component: ImportLandingPage,
|
component: ImportLandingPage,
|
||||||
@ -23,10 +18,7 @@ function ImportLandingPage() {
|
|||||||
const manual = useManualInvoice();
|
const manual = useManualInvoice();
|
||||||
|
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: (files: File[]) =>
|
mutationFn: uploadInvoiceFiles,
|
||||||
api.post<ImportBatchResponse>("/api/v1/invoices/upload", {
|
|
||||||
filenames: files.map((f) => f.name),
|
|
||||||
}),
|
|
||||||
onSuccess: (batch) => {
|
onSuccess: (batch) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${
|
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user