Compare commits

..

No commits in common. "461ab9bcd9fa478baa2c41c1390a54da6659cab9" and "7c80c391f1b35aadac5f2b802f5855c59f8b8059" have entirely different histories.

319 changed files with 10 additions and 38976 deletions

View File

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

View File

@ -1,12 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@ -1,90 +0,0 @@
name: Build & Deploy App
# Workflow pour l'app SaaS (apps/api AdonisJS + apps/web React) déployée
# sur app.rubis.arthurbarre.fr. Image distincte de la landing.
on:
push:
branches: [main]
paths:
- 'apps/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'package.json'
- 'turbo.json'
- 'Dockerfile.app'
- 'k3s/app/**'
- '.gitea/workflows/deploy-app.yml'
env:
REGISTRY: git.arthurbarre.fr
IMAGE: ordinarthur/rubis-app
NAMESPACE: rubis
DEPLOYMENT: rubis-app
CONTAINER: app
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ordinarthur
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push app image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.app
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache,mode=max
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Deploy to K3s
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl apply -f k3s/namespace.yml
# Idempotent : on (re)pose le pull secret du registry Gitea + le
# secret applicatif n'est PAS recréé ici (créé manuellement au
# premier deploy via kubectl, contient des creds qui ne
# transitent jamais par le CI).
kubectl -n $NAMESPACE create secret docker-registry gitea-registry \
--docker-server=$REGISTRY \
--docker-username=ordinarthur \
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
# Apply Redis + app manifests (idempotent)
kubectl apply -f k3s/app/
# Pin l'image avec le sha du commit pour rolling update propre.
# Le init-container migrate utilise la même image et tourne avant
# le serveur — migrations idempotentes via ace migration:run.
kubectl -n $NAMESPACE set image deployment/$DEPLOYMENT \
$CONTAINER=$REGISTRY/$IMAGE:${{ github.sha }}
# Patch aussi le init container (même image)
kubectl -n $NAMESPACE patch deployment $DEPLOYMENT \
--type='json' \
-p="[{\"op\":\"replace\",\"path\":\"/spec/template/spec/initContainers/0/image\",\"value\":\"$REGISTRY/$IMAGE:${{ github.sha }}\"}]"
kubectl -n $NAMESPACE rollout status deployment/$DEPLOYMENT --timeout=300s

View File

@ -1,17 +1,8 @@
name: Build & Deploy Landing
name: Build & Deploy
# Workflow pour la landing static (rubis.arthurbarre.fr).
# L'app SaaS (apps/api + apps/web) a son propre workflow : deploy-app.yml.
on:
push:
branches: [main]
paths:
- 'landing/**'
- 'Dockerfile'
- 'k3s/namespace.yml'
- 'k3s/deployment.yml'
- 'k3s/service.yml'
- '.gitea/workflows/deploy.yml'
env:
REGISTRY: git.arthurbarre.fr

29
.gitignore vendored
View File

@ -1,33 +1,4 @@
.DS_Store
node_modules/
assets/test-invoices/
# Env files (never commit secrets)
.env
.env.local
.env.*.local
# Build artefacts
dist/
build/
*.tsbuildinfo
# Tooling caches
.turbo/
.cache/
coverage/
.eslintcache
# Adonis generated types (regenerated from API source)
apps/api/.adonisjs/
# Generated by TanStack Router
apps/web/src/routeTree.gen.ts
# Generated by MSW (vendored worker)
apps/web/public/mockServiceWorker.js
# Editor
.vscode/
.idea/
*.swp

View File

@ -1,4 +0,0 @@
{
"*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"],
"*.{json,md,css,yml,yaml}": ["prettier --write"]
}

View File

@ -1,10 +0,0 @@
node_modules
dist
build
.turbo
.adonisjs
coverage
pnpm-lock.yaml
landing/index.html
**/routeTree.gen.ts
**/*.gen.ts

View File

@ -1,11 +0,0 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
}

169
AGENTS.md
View File

@ -1,169 +0,0 @@
# 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

@ -109,24 +109,9 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
## 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) |
À confirmer avec Arthur. Stack choisie mais pas encore documentée. *À remplir lors de la prochaine session technique.*
**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.
Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider, email provider, hosting, jobs).
## Documents associés
@ -143,9 +128,6 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
| `/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/marketing/playbook.md` | Playbook acquisition premiers clients : ICP, Dream 100, channels, templates outreach |
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
| `/docs/tech/frontend.md` | Guide d'implémentation frontend (deps, Tailwind, TanStack, Tuyau) |
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
| `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |

View File

@ -1,111 +0,0 @@
# syntax=docker/dockerfile:1.7
# =============================================================================
# Rubis Sur l'Ongle — image production de l'app SaaS (apps/api + apps/web)
# Sert app.rubis.arthurbarre.fr. La landing (rubis.arthurbarre.fr) reste sur
# une image séparée — Dockerfile à la racine, nginx static.
# =============================================================================
#
# Multi-stage :
# - base : node 22 alpine + pnpm + tini
# - deps : install workspace deps (cache friendly via manifests d'abord)
# - build : build shared, web, api ; copie le SPA dans apps/api/build/public
# - runner : copie le repo "pruned" prod, lance node bin/server.js
#
# Choix architectural : un seul process Node sert l'API ET le SPA static
# (via le static middleware AdonisJS + un fallback wildcard pour SPA routing).
# Les workers BullMQ tournent dans le même process (cf. start/queue.ts).
# =============================================================================
ARG NODE_VERSION=22.13.1
ARG PNPM_VERSION=10.0.0
# -----------------------------------------------------------------------------
# base — node + pnpm + tini
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION}-alpine AS base
ARG PNPM_VERSION
RUN apk add --no-cache libc6-compat tini && \
corepack enable && \
corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /repo
# -----------------------------------------------------------------------------
# deps — install workspace (devDeps inclus, on en a besoin pour les builds)
# -----------------------------------------------------------------------------
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# -----------------------------------------------------------------------------
# build — shared → web → api, puis copie du SPA dans le build de l'API
# -----------------------------------------------------------------------------
FROM deps AS build
COPY packages/shared ./packages/shared
COPY apps/web ./apps/web
COPY apps/api ./apps/api
# Builds :
# - @rubis/shared : pas de build (TS source consommé directement via exports).
# - Web : on appelle vite build directement (le `tsc -b` du script de prod
# fait remonter des erreurs DOM dans @tanstack/router-core sans cache
# .tsbuildinfo ; le typecheck est fait en CI séparément).
# - API : `node ace build` (canonique AdonisJS V7) — produit apps/api/build
# avec compiled JS, package.json runtime, et metaFiles configurés.
#
# Note : ce build peut planter en cross-compile ARM→amd64 (swc/core), donc
# en local sur Mac silicon, builder pour --platform linux/arm64. Le CI
# Gitea tourne nativement sur linux/amd64 et n'a pas le problème.
RUN pnpm --filter @rubis/web exec vite build && \
pnpm --filter @rubis/api build
# Le SPA static va dans apps/api/build/public/ pour être servi par le static
# middleware AdonisJS. AdonisJS ne copie pas public/ par défaut dans build/
# (metaFiles vide), on le fait manuellement ici.
RUN mkdir -p apps/api/build/public && \
cp -r apps/web/dist/. apps/api/build/public/
# Prune les devDeps. Les symlinks pnpm vers les workspace packages
# (@rubis/shared) restent valides car on garde le repo en place.
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --prod --frozen-lockfile=false
# -----------------------------------------------------------------------------
# runner — runtime minimal, user non-root
# -----------------------------------------------------------------------------
FROM base AS runner
RUN addgroup -g 1001 -S nodejs && adduser -S adonis -u 1001
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3333 \
LOG_LEVEL=info
WORKDIR /app
# On copie tout le repo pruned (node_modules inclus avec les symlinks
# workspace). C'est plus gros qu'une image "deploy" pure, mais ça évite
# les pièges de résolution workspace pour V1.
COPY --from=build --chown=adonis:nodejs /repo /app
USER adonis
WORKDIR /app/apps/api
EXPOSE 3333
# Healthcheck léger : le serveur HTTP doit répondre 200 sur /api/v1/.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3333/ >/dev/null 2>&1 || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "build/bin/server.js"]

View File

@ -1,22 +0,0 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false

View File

@ -1,80 +0,0 @@
# Node
TZ=UTC
PORT=3333
HOST=0.0.0.0
NODE_ENV=development
# App
LOG_LEVEL=info
APP_KEY=
# APP_URL est l'URL publique (utilisée dans les emails check-in/relance,
# les redirects, etc.). Volontairement découplée de HOST : on bind sur
# 0.0.0.0 mais on expose `localhost` (en dev) ou le vrai domaine (en prod).
APP_URL=http://localhost:3333
# Session
SESSION_DRIVER=cookie
#--------------------------------------------------------------------
# CORS (configure allowed origins for API access)
#--------------------------------------------------------------------
# CORS_ORIGIN=http://localhost:5173,http://localhost:3000
#--------------------------------------------------------------------
# Database (Postgres via docker-compose.dev.yml)
#--------------------------------------------------------------------
DB_CONNECTION=postgres
PG_HOST=localhost
PG_PORT=5433
PG_USER=rubis
PG_PASSWORD=rubis
PG_DB_NAME=rubis_dev
#--------------------------------------------------------------------
# Redis (BullMQ + cache)
#--------------------------------------------------------------------
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_PASSWORD=
#--------------------------------------------------------------------
# Storage (MinIO via S3 driver)
#--------------------------------------------------------------------
DRIVE_DISK=s3
S3_ENDPOINT=http://localhost:9100
S3_REGION=fr-par
S3_BUCKET=rubis-invoices
S3_ACCESS_KEY=rubis
S3_SECRET_KEY=rubis-dev-secret
S3_FORCE_PATH_STYLE=true
#--------------------------------------------------------------------
# Mail (Resend par défaut, Mailpit en fallback dev via MAIL_DRIVER=smtp)
#--------------------------------------------------------------------
MAIL_FROM_ADDRESS=rubis@arthurbarre.fr
MAIL_FROM_NAME=Rubis Sur l'Ongle
MAIL_DRIVER=resend
RESEND_API_KEY=
# Fallback Mailpit (si MAIL_DRIVER=smtp)
SMTP_HOST=localhost
SMTP_PORT=1025
#--------------------------------------------------------------------
# OCR (Mistral)
#--------------------------------------------------------------------
OCR_PROVIDER=mistral
MISTRAL_API_KEY=
#--------------------------------------------------------------------
# Web (URL du SPA, utilisée pour les redirects post-checkin)
#--------------------------------------------------------------------
WEB_URL=http://localhost:5173
#--------------------------------------------------------------------
# Auth (refresh tokens)
#--------------------------------------------------------------------
ACCESS_TOKEN_TTL_MINUTES=30
REFRESH_TOKEN_TTL_DAYS=30
COOKIE_DOMAIN=
COOKIE_SECURE=false
LIMITER_STORE=redis

View File

@ -1,12 +0,0 @@
NODE_ENV=test
SESSION_DRIVER=memory
# Désactive les vraies connexions Redis/MinIO/SMTP pendant les tests.
# Les schedulers détectent NODE_ENV=test et skip BullMQ.add.
DRIVE_DISK=fs
MAIL_DRIVER=smtp
SMTP_HOST=localhost
SMTP_PORT=1025
OCR_PROVIDER=mock
# Utilise la même DB que dev avec global transactions par test (rollback).
# Si tu veux une DB séparée : crée `rubis_test` dans Postgres et override
# PG_DB_NAME=rubis_test ici.

26
apps/api/.gitignore vendored
View File

@ -1,26 +0,0 @@
# Dependencies and AdonisJS build
node_modules
build
tmp/*
!tmp/.gitkeep
# Secrets
.env
.env.local
.env.production.local
.env.development.local
# Frontend assets compiled code
public/assets
# Build tools specific
npm-debug.log
yarn-error.log
# Editors specific
.fleet
.idea
.vscode
# Platform specific
.DS_Store

View File

@ -1,3 +0,0 @@
.adonisjs
node_modules
build

View File

@ -1,27 +0,0 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using @poppinss/ts-exec
*/
import '@poppinss/ts-exec'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')

View File

@ -1,128 +0,0 @@
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
import { indexPolicies } from '@adonisjs/bouncer'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/session/commands'),
() => import('@adonisjs/bouncer/commands')
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/shield/shield_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'),
() => import('@adonisjs/bouncer/bouncer_provider'),
() => import('@adonisjs/limiter/limiter_provider'),
() => import('@adonisjs/mail/mail_provider'),
() => import('@adonisjs/drive/drive_provider'),
() => import('@adonisjs/static/static_provider')
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
() => import('#start/queue'),
],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.{ts,js}'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec.{ts,js}'],
name: 'functional',
timeout: 30000,
},
],
forceExit: false,
},
/*
|--------------------------------------------------------------------------
| Metafiles
|--------------------------------------------------------------------------
|
| A collection of files you want to copy to the build folder when creating
| the production build.
|
*/
metaFiles: [{
pattern: 'public/**',
reloadServer: false,
}],
hooks: {
init: [
indexEntities({
transformers: { enabled: true },
}),
generateRegistry(),
indexPolicies()
],
},
})

View File

@ -1,23 +0,0 @@
/*
|--------------------------------------------------------------------------
| Bouncer abilities
|--------------------------------------------------------------------------
|
| You may export multiple abilities from this file and pre-register them
| when creating the Bouncer instance.
|
| Pre-registered policies and abilities can be referenced as a string by their
| name. Also they are must if want to perform authorization inside Edge
| templates.
|
*/
import { Bouncer } from '@adonisjs/bouncer'
/**
* Delete the following ability to start from
* scratch
*/
export const editUser = Bouncer.ability(() => {
return true
})

View File

@ -1,33 +0,0 @@
import User from '#models/user'
import { loginValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import { emitAuthSession } from '#services/auth_session'
import { revokeCurrentRefreshToken } from '#services/refresh_token'
export default class AccessTokensController {
/**
* POST /auth/login vérifie credentials + émet AuthSession.
*/
async store(ctx: HttpContext) {
const { email, password } = await ctx.request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
const session = await emitAuthSession(user, ctx)
return ctx.serialize(session)
}
/**
* POST /account/logout révoque l'access token courant + le refresh
* token + clear le cookie.
*/
async destroy(ctx: HttpContext) {
const user = ctx.auth.getUserOrFail()
if (user.currentAccessToken) {
await User.accessTokens.delete(user, user.currentAccessToken.identifier)
}
await revokeCurrentRefreshToken(ctx)
ctx.response.status(204)
return null
}
}

View File

@ -1,53 +0,0 @@
import vine from '@vinejs/vine'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import { generateRelance } from '#services/ai_relance_generator'
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
const generateRelanceValidator = vine.create({
tone: vine.enum(RELANCE_TONES),
offsetDays: vine.number().min(-30).max(180),
// Brief libre. On accepte vide : Mistral génère alors un message standard
// pour la tonalité + timing donnés.
prompt: vine.string().maxLength(1000).optional(),
// Contexte du plan parent — nom + description, pour cohérence inter-étapes.
planName: vine.string().maxLength(80).optional(),
planDescription: vine.string().maxLength(500).optional(),
})
/**
* Endpoints IA. V1 : uniquement génération de templates de relance pour le
* wizard de création de plan custom. Mistral est déjà utilisé pour l'OCR
* (cf. mistral_ocr_provider.ts) on réutilise la même clé API.
*/
export default class AiController {
/**
* POST /ai/generate-relance
*
* Génère subject + body avec des placeholders Mustache prêts à insérer.
* L'utilisateur peut régénérer pour avoir une variante.
*/
async generateRelance({ auth, request, response }: HttpContext) {
auth.getUserOrFail() // auth requise
const payload = await request.validateUsing(generateRelanceValidator)
try {
const result = await generateRelance({
tone: payload.tone,
offsetDays: payload.offsetDays,
prompt: payload.prompt ?? '',
planName: payload.planName,
planDescription: payload.planDescription,
})
return response.json({ data: result })
} catch (err) {
// On wrap pour passer par le handler global et garder le format
// d'erreur uniforme côté front.
throw new Exception(
err instanceof Error ? err.message : 'Génération IA indisponible',
{ status: 502, code: 'ai_generation_failed' }
)
}
}
}

View File

@ -1,142 +0,0 @@
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, scheduleRelancesForInvoice } from '#services/relance_scheduler'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { DateTime } from 'luxon'
import type { HttpContext } from '@adonisjs/core/http'
const CHECKIN_TTL_HOURS = 24
/**
* Construit l'URL de redirect SPA selon le résultat. Le SPA lit ces
* query params pour afficher un toast et router l'utilisateur.
*/
function spaRedirectUrl(
result: 'paid' | 'pending' | 'expired' | 'invalid' | 'already_answered',
invoice?: Pick<Invoice, 'id' | 'numero'>
): string {
const base = env.get('WEB_URL', 'http://localhost:5173')
const params = new URLSearchParams({ checkin: result })
if (invoice) params.set('invoice', invoice.numero)
const path = invoice ? `/factures/${invoice.id}` : '/'
return `${base}${path}?${params.toString()}`
}
type ResolvedTask = { task: CheckinTask; invoice: Invoice } | { redirect: string }
/**
* Lookup + validation commune aux deux endpoints (paid / pending).
* Retourne soit la task validée soit une URL de redirect d'erreur.
*/
async function resolveCheckin(token: string): Promise<ResolvedTask> {
const hashed = hashCheckinToken(token)
const task = await CheckinTask.query().where('token_hash', hashed).first()
if (!task) {
return { redirect: spaRedirectUrl('invalid') }
}
if (task.status === 'answered') {
const inv = await Invoice.find(task.invoiceId)
return { redirect: spaRedirectUrl('already_answered', inv ?? undefined) }
}
// Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été
// envoyée, le link n'est pas censé exister côté user — sécurité belt.
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) {
task.status = 'expired'
await task.save()
return { redirect: spaRedirectUrl('expired') }
}
const invoice = await Invoice.query().where('id', task.invoiceId).preload('client').first()
if (!invoice) {
return { redirect: spaRedirectUrl('invalid') }
}
return { task, invoice }
}
export default class CheckinController {
/**
* GET /api/v1/checkin/:token/paid
*
* L'utilisateur clique "j'ai é payé". On marque la facture payée +
* cancel les relances futures + bonus rubis (idempotent avec mark-paid).
* Redirect SPA avec `?checkin=paid&invoice=<numero>`.
*
* Public : pas d'auth Bearer, c'est un lien dans un email.
*/
async respondPaid({ params, response }: HttpContext) {
const result = await resolveCheckin(params.token)
if ('redirect' in result) {
return response.redirect(result.redirect)
}
const { task, invoice } = result
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'paid'
task.answeredAt = DateTime.now()
await task.save()
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
if (invoice.status !== 'paid') {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = DateTime.now()
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
await trx
.from('organizations')
.where('id', invoice.organizationId)
.increment('rubis_count', 1)
await recordActivity({
organizationId: invoice.organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée via check-in`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
await cancelFutureRelances(invoice.id, trx)
}
})
return response.redirect(spaRedirectUrl('paid', invoice))
}
/**
* GET /api/v1/checkin/:token/pending
*
* L'utilisateur clique "toujours en attente". On marque la task
* answered, puis on programme les relances client.
*/
async respondPending({ params, response }: HttpContext) {
const result = await resolveCheckin(params.token)
if ('redirect' in result) {
return response.redirect(result.redirect)
}
const { task, invoice } = result
await db.transaction(async (trx) => {
if (invoice.planId) {
invoice.useTransaction(trx)
await scheduleRelancesForInvoice(invoice, trx)
}
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'still_pending'
task.answeredAt = DateTime.now()
await task.save()
})
return response.redirect(spaRedirectUrl('pending', invoice))
}
}

View File

@ -1,172 +0,0 @@
import Client from '#models/client'
import ClientTransformer from '#transformers/client_transformer'
import { createClientValidator, updateClientValidator } from '#validators/client'
import { bulkComputeClientStats } from '#services/client_stats'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
/**
* Petite cohérence d'identification orgnisation : si l'utilisateur
* n'en a pas, on est dans un état illégal V1 on bloque ferme.
*/
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
/**
* Sérialisation directe (instanciation manuelle du transformer pour
* éviter le wrapper Item utile quand on doit fusionner des stats
* computed par-dessus chaque client dans une liste).
*/
function serializeClient(c: Client) {
return new ClientTransformer(c).toObject()
}
export default class ClientsController {
/**
* GET /clients?withStats=1&q=
*
* Sans `withStats`, retour à plat (utilisé par le combobox de saisie).
* Avec `withStats`, chaque client est enrichi des compteurs de factures
* et trié par actionnabilité (retards d'abord, puis activité récente).
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const withStats = request.input('withStats') === '1'
const q = (request.input('q') ?? '').toString().trim().toLowerCase()
const query = Client.query().where('organization_id', organizationId)
if (q.length > 0) {
query.where((b) => {
b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`)
})
}
const clients = await query.exec()
if (!withStats) {
// Tri alphabétique par défaut pour le combobox.
clients.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
return response.json({ data: clients.map(serializeClient) })
}
const statsMap = await bulkComputeClientStats(
organizationId,
clients.map((c) => c.id)
)
const enriched = clients.map((c) => ({
...serializeClient(c),
...statsMap.get(c.id)!,
}))
// Tri actionnable : retards d'abord, puis activité récente.
enriched.sort((a, b) => {
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
return b.lateInvoiceCount - a.lateInvoiceCount
}
const aLast = a.lastActivityAt ?? ''
const bLast = b.lastActivityAt ?? ''
return bLast.localeCompare(aLast)
})
return response.json({ data: enriched })
}
/**
* GET /clients/:id détail enrichi (stats + invoices à venir).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
const stats = statsMap.get(client.id)!
return response.json({
data: {
...serializeClient(client),
...stats,
invoices: [], // TODO: brancher quand le domaine Invoice arrive
},
})
}
/**
* POST /clients création manuelle.
* Détecte les doublons de nom (case-insensitive) et renvoie 409 avec
* la fiche existante pour permettre au SPA de proposer "voir le client".
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createClientValidator)
// Doublon → 409 (cf. clients.ts MSW pour le contrat exact).
const existing = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', payload.name)
.first()
if (existing) {
return response.status(409).json({
errors: [
{
code: 'duplicate_client',
message: `Un client nommé « ${existing.name} » existe déjà.`,
field: 'name',
},
],
existing: serializeClient(existing),
})
}
const created = await Client.create({
organizationId,
name: payload.name,
email: payload.email,
contactFirstName: payload.contactFirstName ?? null,
contactLastName: payload.contactLastName ?? null,
phone: payload.phone ?? null,
address: payload.address ?? null,
siret: payload.siret ?? null,
notes: payload.notes ?? null,
})
return response.status(201).json({ data: serializeClient(created) })
}
/**
* PATCH /clients/:id édition partielle.
*/
async update({ auth, request, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updateClientValidator)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
client.merge(payload)
await client.save()
return response.json({ data: serializeClient(client) })
}
}

View File

@ -1,65 +0,0 @@
import ActivityEvent from '#models/activity_event'
import { computeKpis, topLatePayers } from '#services/dashboard'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
const ACTIVITY_DEFAULT_LIMIT = 20
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
export default class DashboardController {
/**
* GET /dashboard/kpis
*
* Cf. service dashboard.ts quelques metrics V1 sont placeholder
* (miseEnDemeurePending=0 tant que RelanceTask pas branché, percentile
* undefined tant que cohorte trop petite).
*/
async kpis({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await computeKpis(organizationId)
return response.json({ data })
}
/**
* GET /dashboard/activity
*
* Journal append-only. Limit 20 par défaut, plus récent en tête.
*/
async activity({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const events = await ActivityEvent.query()
.where('organization_id', organizationId)
.orderBy('at', 'desc')
.limit(ACTIVITY_DEFAULT_LIMIT)
return response.json({
data: events.map((e) => ({
id: e.id,
kind: e.kind,
at: e.at.toISO()!,
label: e.label,
meta: e.meta,
})),
})
}
/**
* GET /dashboard/top-late
*
* Top 5 clients avec le plus de factures en retard (status actif +
* due_date dépassée).
*/
async topLate({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await topLatePayers(organizationId)
return response.json({ data })
}
}

View File

@ -1,267 +0,0 @@
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 InvoiceTransformer from '#transformers/invoice_transformer'
import { uploadValidator, validateDraftValidator } from '#validators/import_batch'
import { resolveClient } from '#services/resolve_client'
import {
createImportBatch,
createImportBatchFromFilenames,
type ImportSource,
} from '#services/import_batch'
import { recordActivity } from '#services/activity_recorder'
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger'
import drive from '@adonisjs/drive/services/main'
import { createReadStream } from 'node:fs'
import { randomUUID } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializeBatch(b: ImportBatch) {
return new ImportBatchTransformer(b).toObject()
}
async function loadBatchOrFail(organizationId: string, id: string): Promise<ImportBatch> {
const batch = await ImportBatch.query()
.where('organization_id', organizationId)
.where('id', id)
.preload('drafts', (q) => q.orderBy('created_at', 'asc'))
.first()
if (!batch) {
throw new Exception('Batch introuvable', { status: 404, code: 'not_found' })
}
return batch
}
export default class ImportBatchesController {
/**
* POST /invoices/upload démarre un batch OCR.
*
* Deux modes selon Content-Type :
* - **multipart/form-data** : champ `files[]` avec les vrais PDFs.
* Stockage MinIO + OCR (mock OU mistral selon OCR_PROVIDER).
* - **application/json** : `{ filenames: string[] }` (V1 démo).
* Aucun fichier stocké ne marche QU'AVEC OCR_PROVIDER=mock.
*/
async upload(ctx: HttpContext) {
const { auth, request, response } = ctx
const organizationId = requireOrgId(auth)
const isMultipart = (request.header('content-type') ?? '').startsWith('multipart/')
if (isMultipart) {
const files = request.files('files', {
size: '10mb',
extnames: ['pdf', 'png', 'jpg', 'jpeg'],
})
if (files.length === 0) {
return response.status(422).json({
errors: [
{ code: 'validation_failed', field: 'files', message: 'Au moins un fichier requis' },
],
})
}
// Upload vers Drive (MinIO) AVANT l'OCR — l'OCR Mistral télécharge
// depuis Drive donc il faut que le fichier soit déjà posé.
// Clé : import-drafts/<orgId>/<draftId>.<ext> — pas de batchId
// dans la clé car le batch est créé après.
const sources: ImportSource[] = []
for (const f of files) {
if (!f.isValid || !f.tmpPath || !f.extname) {
return response.status(422).json({
errors: [
{
code: 'validation_failed',
field: 'files',
message: f.errors?.[0]?.message ?? 'Fichier invalide',
},
],
})
}
const draftKey = randomUUID()
const storageKey = `import-drafts/${organizationId}/${draftKey}.${f.extname}`
await drive.use().putStream(storageKey, createReadStream(f.tmpPath))
sources.push({
filename: f.clientName ?? `${draftKey}.${f.extname}`,
storageKey,
})
}
const batch = await createImportBatch(organizationId, sources)
return response.status(201).json({ data: serializeBatch(batch) })
}
// Mode JSON — compat V1 démo.
const { filenames } = await request.validateUsing(uploadValidator)
const batch = await createImportBatchFromFilenames(organizationId, filenames)
return response.status(201).json({ data: serializeBatch(batch) })
}
/**
* GET /invoices/import-batch/:id état courant d'un batch.
* Le SPA poll cet endpoint pendant la review (drafts pending validated/skipped).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
return response.json({ data: serializeBatch(batch) })
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate
*
* L'utilisateur valide un draft → on crée l'Invoice avec les champs
* éventuellement édités. Même logique de résolution client que POST
* /invoices (clientId match nom création + email requis).
*/
async validateDraft({ auth, params, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(validateDraftValidator)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
}
if (draft.status !== 'pending') {
throw new Exception('Brouillon déjà traité', {
status: 409,
code: 'draft_already_processed',
})
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(
organizationId,
{
clientId: fields.clientId,
clientName: fields.clientName,
clientEmail: fields.clientEmail,
},
trx
)
if ('errorCode' in result) {
throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: result.errorCode }
)
}
const client = result.client
// Plan : si fourni, doit appartenir à l'org.
let planId: string | null = null
if (fields.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.planId)
.first()
if (plan) planId = plan.id
}
const created = await Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: DateTime.fromISO(fields.issueDate),
dueDate: DateTime.fromISO(fields.dueDate),
status: 'pending',
rubisEarned: 1, // bonus import OCR (cf. CLAUDE.md → glossaire)
pdfStorageKey: draft.pdfStorageKey,
notes: null,
paidAt: null,
},
{ client: trx }
)
draft.useTransaction(trx)
draft.status = 'validated'
draft.edited = {
clientId: client.id,
clientName: client.name,
clientEmail: client.email,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
planId,
}
draft.invoiceId = created.id
await draft.save()
await recordActivity({
organizationId,
kind: 'invoice_imported',
label: `Facture <b>${created.numero}</b> importée et validée`,
meta: { invoiceId: created.id, clientId: client.id },
trx,
})
return created
})
await invoice.load('client')
await invoice.load('plan')
try {
await scheduleCheckinForInvoice(invoice)
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
}
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/skip
* Marque un brouillon comme skippé (l'utilisateur ne veut pas le valider).
*/
async skipDraft({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
}
if (draft.status === 'validated') {
throw new Exception('Brouillon déjà validé', {
status: 409,
code: 'draft_already_processed',
})
}
if (draft.status !== 'skipped') {
draft.status = 'skipped'
await draft.save()
}
return response.json({ data: serializeDraft(draft) })
}
/**
* DELETE /invoices/import-batch/:id annule le batch entier.
* CASCADE supprime les drafts. Les invoices validées (si y'en a déjà)
* restent intactes, le FK draft.invoice_id est SET NULL côté ImportDraft.
*/
async destroy({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
await batch.delete()
return response.status(204).send('')
}
}

View File

@ -1,376 +0,0 @@
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 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 { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger'
const PAGE_SIZE = 50
// Priorité d'affichage côté liste : ce qui est actionnable d'abord.
const STATUS_PRIORITY: Record<string, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
}
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializeInvoice(i: Invoice) {
return new InvoiceTransformer(i).toObject()
}
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant (V1 simplifié les RelanceTask viendront plus tard).
*
* - étapes dont sendDay <= aujourd'hui : 'past' (envoyées)
* - étape actuelle (la prochaine future) : 'current'
* - étapes futures : 'future'
*/
function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = []
): Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> {
const events: Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> = [
{
id: `${invoice.id}__issued`,
state: 'past',
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
what: 'Importée',
},
]
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 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 (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,
})
}
}
if (invoice.status === 'paid' && invoice.paidAt) {
events.push({
id: `${invoice.id}__paid`,
state: 'past',
when: `${formatShortDate(invoice.paidAt)} · facture encaissée`,
what: 'Marquée encaissée — relances stoppées',
})
}
return events
}
function formatShortDate(d: DateTime): string {
return d.toFormat('dd/LL/yyyy')
}
export default class InvoicesController {
/**
* GET /invoices?status=&q=&clientId=&page=
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const filters = await request.validateUsing(listInvoicesValidator)
const query = Invoice.query()
.where('organization_id', organizationId)
.preload('client')
.preload('plan')
if (filters.status && filters.status !== 'all') {
query.where('status', filters.status)
}
if (filters.clientId) {
query.where('client_id', filters.clientId)
}
if (filters.q) {
const q = filters.q.toLowerCase()
query.where((b) => {
b.whereILike('numero', `%${q}%`).orWhereExists((sub) => {
sub
.from('clients')
.whereColumn('clients.id', 'invoices.client_id')
.whereILike('clients.name', `%${q}%`)
})
})
}
const invoices = await query.exec()
// Tri : actionnable d'abord (status priority), puis échéance croissante.
invoices.sort((a, b) => {
const dp = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99)
if (dp !== 0) return dp
return a.dueDate.toMillis() - b.dueDate.toMillis()
})
// Pagination simple en V1 (cf. backend.md §6 — cursor-based plus tard).
const page = filters.page ?? 1
const total = invoices.length
const sliced = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return response.json({
data: sliced.map(serializeInvoice),
meta: { total, page },
})
}
/**
* GET /invoices/counts compteurs par statut pour les chips dashboard.
*/
async counts({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.select('status')
.count('* as count')
.groupBy('status')
const counts = {
all: 0,
pending: 0,
in_relance: 0,
awaiting_user_confirmation: 0,
paid: 0,
litigation: 0,
cancelled: 0,
}
for (const r of rows) {
const c = Number(r.count)
counts.all += c
const s = r.status as keyof typeof counts
if (s in counts) counts[s] = c
}
return response.json({ data: counts })
}
/**
* GET /invoices/:id détail enrichi (client + plan + timeline).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan', (q) => q.preload('steps'))
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
const data = serializeInvoice(invoice)
const relanceTasks = await RelanceTask.query()
.where('invoice_id', invoice.id)
.whereNot('status', 'cancelled')
return response.json({
data: {
...data,
client: invoice.client && {
id: invoice.client.id,
name: invoice.client.name,
email: invoice.client.email,
phone: invoice.client.phone,
address: invoice.client.address,
siret: invoice.client.siret,
},
plan: invoice.plan && {
id: invoice.plan.id,
slug: invoice.plan.slug,
name: invoice.plan.name,
steps: (invoice.plan.steps ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((s) => ({
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
},
timeline: buildTimeline(invoice, relanceTasks),
},
})
}
/**
* POST /invoices saisie manuelle.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(createInvoiceValidator)
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(organizationId, fields, trx)
if ('errorCode' in result) {
throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: result.errorCode }
)
}
const client = result.client
// Vérification plan (s'il est fourni, doit appartenir à l'org).
let planId: string | null = null
if (fields.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.planId)
.first()
if (plan) planId = plan.id
}
return Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: DateTime.fromISO(fields.issueDate),
dueDate: DateTime.fromISO(fields.dueDate),
status: 'pending',
rubisEarned: 1, // bonus saisie initiale (cf. CLAUDE.md → glossaire)
pdfStorageKey: null,
notes: null,
paidAt: null,
},
{ client: trx }
)
})
await invoice.load('client')
await invoice.load('plan')
// Programme uniquement le check-in (envoyé à dueDate). Les relances
// client ne partent qu'après confirmation "toujours en attente".
try {
await scheduleCheckinForInvoice(invoice)
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
}
return response.status(201).json({ data: serializeInvoice(invoice) })
}
/**
* POST /invoices/:id/mark-paid
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
* et sur organization.rubisCount). Annule toutes les relances futures.
*/
async markPaid({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan')
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (invoice.status === 'paid') {
// Idempotent : déjà payée, on renvoie l'état courant sans bumper.
return response.json({ data: serializeInvoice(invoice) })
}
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = DateTime.now()
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
// Bump du compteur agrégé sur l'organisation
await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
// Journal d'activité (cf. dashboard activity feed).
await recordActivity({
organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
// Annule toutes les relances + le check-in programmés pour cette
// facture (idempotent, BullMQ.remove peut échouer silencieusement
// si le job a déjà été consommé).
await cancelFutureRelances(invoice.id, trx)
await cancelCheckinForInvoice(invoice.id, trx)
})
return response.json({ data: serializeInvoice(invoice) })
}
}

View File

@ -1,31 +0,0 @@
import User from '#models/user'
import Organization from '#models/organization'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import { provisionDefaultPlans } from '#services/default_plans'
import { emitAuthSession } from '#services/auth_session'
export default class NewAccountController {
/**
* POST /auth/signup
* Crée organisation + 4 plans pré-fournis + user dans une transaction,
* puis émet une AuthSession (access token JSON + refresh cookie httpOnly).
*/
async store(ctx: HttpContext) {
const { fullName, email, password } = await ctx.request.validateUsing(signupValidator)
const user = await db.transaction(async (trx) => {
const org = await Organization.create({ name: '' }, { client: trx })
await provisionDefaultPlans(org.id, trx)
return User.create(
{ email, password, fullName, organizationId: org.id },
{ client: trx }
)
})
const session = await emitAuthSession(user, ctx)
ctx.response.status(201)
return ctx.serialize(session)
}
}

View File

@ -1,47 +0,0 @@
import Organization from '#models/organization'
import OrganizationTransformer from '#transformers/organization_transformer'
import { updateOrganizationValidator } from '#validators/organization'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
export default class OrganizationsController {
/**
* GET /organizations/me l'organisation de l'utilisateur courant.
*/
async show({ auth, serialize }: HttpContext) {
const user = auth.getUserOrFail()
if (user.organizationId === null) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
const org = await Organization.findOrFail(user.organizationId)
return serialize(OrganizationTransformer.transform(org))
}
/**
* PATCH /organizations/me onboarding step 2.
* Marque `onboardingCompletedAt` dès qu'un nom est posé pour la
* première fois (heuristique simple : pour l'instant un nom non vide
* suffit à considérer l'organisation comme "configurée").
*/
async update({ auth, request, serialize }: HttpContext) {
const user = auth.getUserOrFail()
if (user.organizationId === null) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
const payload = await request.validateUsing(updateOrganizationValidator)
const org = await Organization.findOrFail(user.organizationId)
const wasUnnamed = org.name.trim().length === 0
org.merge(payload)
if (wasUnnamed && (payload.name?.trim().length ?? 0) > 0 && !org.onboardingCompletedAt) {
org.onboardingCompletedAt = DateTime.now()
}
await org.save()
return serialize(OrganizationTransformer.transform(org))
}
}

View File

@ -1,228 +0,0 @@
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
import PlanTransformer from '#transformers/plan_transformer'
import { createPlanValidator, updatePlanValidator } from '#validators/plan'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
/**
* Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets.
* On garantit l'unicité par org en suffixant un compteur si collision.
*/
function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'plan'
}
// Slugs réservés côté front (routes statiques type /plans/nouveau).
// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office.
const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create'])
async function nextAvailableSlug(organizationId: string, base: string): Promise<string> {
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base
const existing = await Plan.query()
.where('organization_id', organizationId)
.whereILike('slug', `${base}%`)
.select('slug')
const taken = new Set(existing.map((p) => p.slug))
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start
for (let i = 2; i < 100; i++) {
const candidate = `${base}-${i}`
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate
}
return `${base}-${Date.now()}`
}
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializePlan(p: Plan) {
return new PlanTransformer(p).toObject()
}
/**
* Compte combien de factures actives (non payées, non annulées) référencent
* chaque plan d'une org. Utilisé pour enrichir la liste avec un badge "X
* factures utilisent ce plan" utile avant édition pour signaler l'impact.
*/
async function bulkComputePlanUsage(
organizationId: string,
planIds: string[]
): Promise<Map<string, number>> {
const map = new Map<string, number>()
for (const id of planIds) map.set(id, 0)
if (planIds.length === 0) return map
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('plan_id', planIds)
.whereRaw(`status::text in ${ACTIVE_INVOICE_STATUSES}`)
.select('plan_id')
.count('* as count')
.groupBy('plan_id')
for (const r of rows) {
map.set(r.plan_id, Number(r.count))
}
return map
}
export default class PlansController {
/**
* GET /plans liste enrichie avec compteurs d'usage.
*/
async index({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const plans = await Plan.query()
.where('organization_id', organizationId)
.preload('steps')
.orderBy('is_default', 'desc')
.orderBy('name', 'asc')
const usage = await bulkComputePlanUsage(
organizationId,
plans.map((p) => p.id)
)
const data = plans.map((p) => ({
...serializePlan(p),
usageCount: usage.get(p.id) ?? 0,
}))
return response.json({ data })
}
/**
* GET /plans/:slug détail.
* Le SPA lookup par slug pour les plans pré-fournis (URL stable et
* lisible : /parametres/plans/standard-30j).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const plan = await Plan.query()
.where('organization_id', organizationId)
.where('slug', params.slug)
.preload('steps')
.first()
if (!plan) {
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
}
const usage = await bulkComputePlanUsage(organizationId, [plan.id])
return response.json({
data: { ...serializePlan(plan), usageCount: usage.get(plan.id) ?? 0 },
})
}
/**
* PATCH /plans/:slug édite nom, description et/ou recompose les étapes.
*
* Recomposition des steps : on ne fait pas de diff fin (id par id), on
* remplace tout le set en transaction. Plus simple, plus prévisible, et
* idiomatique côté UX (l'utilisateur a édité son plan dans son ensemble).
*/
async update({ auth, params, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updatePlanValidator)
const plan = await Plan.query()
.where('organization_id', organizationId)
.where('slug', params.slug)
.first()
if (!plan) {
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
}
await db.transaction(async (trx) => {
plan.useTransaction(trx)
if (payload.name !== undefined) plan.name = payload.name
if (payload.description !== undefined) plan.description = payload.description
await plan.save()
if (payload.steps !== undefined) {
// Remplace tout le set
await PlanStep.query({ client: trx }).where('plan_id', plan.id).delete()
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: plan.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
}
})
await plan.load('steps')
return response.json({ data: serializePlan(plan) })
}
/**
* POST /plans création d'un plan custom.
*
* Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org.
* Le plan custom n'est pas marqué `isDefault` il peut être supprimé
* (V2) sans toucher à la bibliothèque.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createPlanValidator)
const baseSlug = slugify(payload.name)
const slug = await nextAvailableSlug(organizationId, baseSlug)
const plan = await db.transaction(async (trx) => {
const created = await Plan.create(
{
organizationId,
slug,
name: payload.name,
description: payload.description ?? '',
isDefault: false,
},
{ client: trx }
)
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: created.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
return created
})
await plan.load('steps')
return response.status(201).json({ data: serializePlan(plan) })
}
}

View File

@ -1,25 +0,0 @@
import UserTransformer from '#transformers/user_transformer'
import { updateProfileValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class ProfileController {
/**
* GET /account/profile
*/
async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.getUserOrFail()))
}
/**
* PATCH /account/profile
*/
async update({ auth, request, serialize }: HttpContext) {
const user = auth.getUserOrFail()
const payload = await request.validateUsing(updateProfileValidator)
user.merge(payload)
await user.save()
return serialize(UserTransformer.transform(user))
}
}

View File

@ -1,32 +0,0 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { consumeRefreshToken } from '#services/refresh_token'
import { emitAuthSession } from '#services/auth_session'
export default class RefreshController {
/**
* POST /auth/refresh
*
* Lit le cookie `rubis_refresh` (httpOnly), valide son hash en DB,
* révoque l'ancien et émet une AuthSession fraîche (nouveau access
* token + nouveau refresh cookie posé via emitAuthSession).
*
* Codes d'erreur :
* - 401 no_session : pas de cookie envoyé
* - 401 session_expired : cookie inconnu, expiré, ou révoqué
* (réutilisation d'un token révoqué = vol présumé panic mode :
* tous les tokens actifs du user sont invalidés)
*/
async handle(ctx: HttpContext) {
const result = await consumeRefreshToken(ctx)
if ('errorCode' in result) {
throw new Exception(
result.errorCode === 'no_session' ? 'Pas de session active' : 'Session expirée',
{ status: 401, code: result.errorCode }
)
}
const session = await emitAuthSession(result.user, ctx)
return ctx.serialize(session)
}
}

View File

@ -1,96 +0,0 @@
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
/**
* Exception handler API JSON-only. Normalise toutes les erreurs vers la
* shape `{ errors: [{ code, message, field? }] }` documentée dans
* backend.md §6.
*
* Conversions :
* - PG 23505 (unique violation) 422 `duplicate` avec field extrait
* - E_INVALID_CREDENTIALS 401 `invalid_credentials`
* - Vine validation errors 422 (déjà géré par Adonis, on relaie)
* - Exception custom avec code & status propage tel quel sous shape errors
* - Reste fallback super.handle()
*/
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (!isObject(error)) return super.handle(error, ctx)
// Postgres unique violation → 422 propre (pas un 500 avec stack pg-protocol).
if (error.code === '23505') {
const detail = typeof error.detail === 'string' ? error.detail : ''
const fieldMatch = detail.match(/Key \(([^)]+)\)=/)
const field = fieldMatch?.[1]?.split(',')[0]?.trim()
ctx.response.status(422)
return ctx.response.json({
errors: [
{
code: 'duplicate',
message: 'Cette valeur existe déjà.',
field: field ?? undefined,
},
],
})
}
// Adonis auth — mauvais credentials. Le default est 400, on veut 401.
if (error.code === 'E_INVALID_CREDENTIALS') {
ctx.response.status(401)
return ctx.response.json({
errors: [
{
code: 'invalid_credentials',
message: 'Email ou mot de passe incorrect',
},
],
})
}
// Vine — validation errors. Adonis sort déjà des messages structurés,
// on les relaie en `errors[]`.
if (error.code === 'E_VALIDATION_ERROR' && Array.isArray(error.messages)) {
ctx.response.status(422)
return ctx.response.json({
errors: error.messages.map((m) => ({
code: 'validation_failed',
message: typeof m === 'object' && m && 'message' in m ? String(m.message) : '',
field: typeof m === 'object' && m && 'field' in m ? String(m.field) : undefined,
rule: typeof m === 'object' && m && 'rule' in m ? String(m.rule) : undefined,
})),
})
}
// Custom Exception levée par les controllers : on a `status` + `code`
// + `message`. On les passe en shape `errors[]`.
if (
typeof error.status === 'number' &&
typeof error.code === 'string' &&
typeof error.message === 'string' &&
error.status >= 400 &&
error.status < 600
) {
ctx.response.status(error.status)
return ctx.response.json({
errors: [
{
code: error.code,
message: error.message,
},
],
})
}
return super.handle(error, ctx)
}
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object'
}

View File

@ -1,69 +0,0 @@
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import User from '#models/user'
import { sendCheckinEmail } from '#services/mail_dispatcher'
import env from '#start/env'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
* Worker BullMQ pour la queue `checkins`.
*
* Idempotent : si la task n'est plus `scheduled` (déjà envoyée ou
* expirée parce que la facture a é marquée payée entre-temps),
* no-op.
*
* Le `plain` token est passé dans le payload du job (pas relu depuis
* la DB on n'a que le hash), pour pouvoir construire les URLs.
*/
export async function sendCheckinJob(jobData: { taskId: string; plain: string }) {
const task = await CheckinTask.find(jobData.taskId)
if (!task) {
logger.warn({ taskId: jobData.taskId }, 'checkin task not found, skipping')
return
}
if (task.status !== 'scheduled') {
return
}
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.first()
if (!invoice) {
task.status = 'expired'
await task.save()
return
}
// Si la facture a été payée/annulée entre la programmation et l'exécution,
// on n'envoie pas le check-in (l'utilisateur sait déjà).
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
task.status = 'expired'
await task.save()
return
}
const user = await User.query().where('organization_id', invoice.organizationId).first()
if (!user) {
task.status = 'expired'
await task.save()
return
}
const apiUrl = env.get('APP_URL', 'http://localhost:3333')
const paidUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/paid`
const pendingUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/pending`
await sendCheckinEmail({
invoice,
client: invoice.client,
user,
paidUrl,
pendingUrl,
})
task.status = 'sent'
task.sentAt = DateTime.now()
await task.save()
}

View File

@ -1,122 +0,0 @@
import RelanceTask from '#models/relance_task'
import Invoice from '#models/invoice'
import User from '#models/user'
import { sendRelanceEmail } from '#services/mail_dispatcher'
import { recordActivity } from '#services/activity_recorder'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
* Worker BullMQ pour la queue `relances`. Idempotent : si la task n'est
* plus `scheduled` (déjà envoyée, annulée, ou échouée définitivement),
* no-op.
*
* Cas critiques :
* - Invoice payée/annulée entre temps cancel la task (pas d'envoi)
* - Step `requires_manual_validation` (mise en demeure) on n'envoie
* PAS, on log un activity_event 'warning_drafted' que l'utilisateur
* devra valider manuellement (cf. CLAUDE.md Principes produit).
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
*/
export async function sendRelanceJob(jobData: { taskId: string }) {
const task = await RelanceTask.query()
.where('id', jobData.taskId)
.preload('planStep')
.first()
if (!task) {
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
return
}
if (task.status !== 'scheduled') {
logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
return
}
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.preload('organization')
.first()
if (!invoice) {
task.status = 'cancelled'
await task.save()
return
}
// Hook critique : la facture peut avoir été payée entre la programmation
// et l'exécution. On vérifie avant d'envoyer.
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
task.status = 'cancelled'
await task.save()
return
}
const step = task.planStep
const user = await User.query().where('organization_id', invoice.organizationId).first()
// Mise en demeure : on génère un brouillon, on n'envoie pas (cf. CLAUDE.md).
if (step.requiresManualValidation) {
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output
task.sentAt = DateTime.now()
await task.save()
await recordActivity({
organizationId: invoice.organizationId,
kind: 'warning_drafted',
label: `Brouillon mise en demeure prêt — <b>${invoice.client.name}</b> (${invoice.numero})`,
meta: {
invoiceId: invoice.id,
clientId: invoice.clientId,
planStepOrder: step.order,
},
trx,
})
})
return
}
// Envoi normal
await sendRelanceEmail({
invoice,
client: invoice.client,
step,
user,
organization: invoice.organization,
})
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent'
task.sentAt = DateTime.now()
await task.save()
invoice.useTransaction(trx)
// Première relance envoyée → status passe en `in_relance` (la facture
// sort de l'état "pending" silencieux).
if (invoice.status === 'pending') {
invoice.status = 'in_relance'
}
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
await trx
.from('organizations')
.where('id', invoice.organizationId)
.increment('rubis_count', 1)
await recordActivity({
organizationId: invoice.organizationId,
kind: 'relance_sent',
label: `Relance J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} envoyée à <b>${invoice.client.name}</b>`,
meta: {
invoiceId: invoice.id,
clientId: invoice.clientId,
planStepOrder: step.order,
},
trx,
})
})
}

View File

@ -1,20 +0,0 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards)
return next()
}
}

View File

@ -1,19 +0,0 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}

View File

@ -1,9 +0,0 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class ForceJsonResponseMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.request.request.headers.accept = 'application/json'
return next()
}
}

View File

@ -1,37 +0,0 @@
import * as abilities from '#abilities/main'
import { policies } from '#generated/policies'
import { Bouncer } from '@adonisjs/bouncer'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Init bouncer middleware is used to create a bouncer instance
* during an HTTP request
*/
export default class InitializeBouncerMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Create bouncer instance for the ongoing HTTP request.
* We will pull the user from the HTTP context.
*/
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
abilities,
policies
).setContainerResolver(ctx.containerResolver)
// API JSON-only : pas d'intégration Edge views à partager.
return next()
}
}
declare module '@adonisjs/core/http' {
export interface HttpContext {
bouncer: Bouncer<
Exclude<HttpContext['auth']['user'], undefined>,
typeof abilities,
typeof policies
>
}
}

View File

@ -1,16 +0,0 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Silent auth middleware can be used as a global middleware to silent check
* if the user is logged-in or not.
*
* The request continues as usual, even when the user is not logged-in.
*/
export default class SilentAuthMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
await ctx.auth.check()
return next()
}
}

View File

@ -1,9 +0,0 @@
import { ActivityEventSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class ActivityEvent extends ActivityEventSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
}

View File

@ -1,9 +0,0 @@
import { CheckinTaskSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Invoice from '#models/invoice'
export default class CheckinTask extends CheckinTaskSchema {
@belongsTo(() => Invoice)
declare invoice: BelongsTo<typeof Invoice>
}

View File

@ -1,13 +0,0 @@
import { ClientSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import Invoice from '#models/invoice'
export default class Client extends ClientSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => Invoice)
declare invoices: HasMany<typeof Invoice>
}

View File

@ -1,13 +0,0 @@
import { ImportBatchSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import ImportDraft from '#models/import_draft'
export default class ImportBatch extends ImportBatchSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => ImportDraft, { foreignKey: 'batchId' })
declare drafts: HasMany<typeof ImportDraft>
}

View File

@ -1,13 +0,0 @@
import { ImportDraftSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import ImportBatch from '#models/import_batch'
import Invoice from '#models/invoice'
export default class ImportDraft extends ImportDraftSchema {
@belongsTo(() => ImportBatch, { foreignKey: 'batchId' })
declare batch: BelongsTo<typeof ImportBatch>
@belongsTo(() => Invoice, { foreignKey: 'invoiceId' })
declare invoice: BelongsTo<typeof Invoice>
}

View File

@ -1,17 +0,0 @@
import { InvoiceSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import Client from '#models/client'
import Plan from '#models/plan'
export default class Invoice extends InvoiceSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@belongsTo(() => Client)
declare client: BelongsTo<typeof Client>
@belongsTo(() => Plan)
declare plan: BelongsTo<typeof Plan>
}

View File

@ -1,9 +0,0 @@
import { OrganizationSchema } from '#database/schema'
import { hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class Organization extends OrganizationSchema {
@hasMany(() => User)
declare users: HasMany<typeof User>
}

View File

@ -1,17 +0,0 @@
import { PlanSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import PlanStep from '#models/plan_step'
import Invoice from '#models/invoice'
export default class Plan extends PlanSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => PlanStep, { foreignKey: 'planId' })
declare steps: HasMany<typeof PlanStep>
@hasMany(() => Invoice)
declare invoices: HasMany<typeof Invoice>
}

View File

@ -1,9 +0,0 @@
import { PlanStepSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Plan from '#models/plan'
export default class PlanStep extends PlanStepSchema {
@belongsTo(() => Plan)
declare plan: BelongsTo<typeof Plan>
}

View File

@ -1,9 +0,0 @@
import { RefreshTokenSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class RefreshToken extends RefreshTokenSchema {
@belongsTo(() => User)
declare user: BelongsTo<typeof User>
}

View File

@ -1,13 +0,0 @@
import { RelanceTaskSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Invoice from '#models/invoice'
import PlanStep from '#models/plan_step'
export default class RelanceTask extends RelanceTaskSchema {
@belongsTo(() => Invoice)
declare invoice: BelongsTo<typeof Invoice>
@belongsTo(() => PlanStep, { foreignKey: 'planStepId' })
declare planStep: BelongsTo<typeof PlanStep>
}

View File

@ -1,24 +0,0 @@
import { UserSchema } from '#database/schema'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User)
declare currentAccessToken?: AccessToken
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
get initials() {
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
if (first && last) {
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
}
return `${first.slice(0, 2)}`.toUpperCase()
}
}

View File

@ -1,40 +0,0 @@
import { DateTime } from 'luxon'
import ActivityEvent from '#models/activity_event'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
type RecordOpts = {
organizationId: string
kind: EventKind
label: string
meta?: Record<string, unknown>
at?: DateTime
trx?: TransactionClientContract
}
/**
* Enregistre un événement dans le journal d'activité (append-only).
* Appelé depuis :
* - SendRelanceJob (relance_sent)
* - InvoicesController.markPaid (invoice_paid)
* - ImportBatchesController.validateDraft (invoice_imported)
* - SendRelanceJob quand step.requires_manual_validation (warning_drafted)
*
* Les labels acceptent un HTML léger (<b>) pour permettre au SPA de
* mettre en gras les noms d'entité toujours composé côté serveur,
* jamais d'input utilisateur brut.
*/
export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
const { organizationId, kind, label, meta = {}, at, trx } = opts
return ActivityEvent.create(
{
organizationId,
kind,
label,
meta,
at: at ?? DateTime.now(),
},
trx ? { client: trx } : undefined
)
}

View File

@ -1,156 +0,0 @@
import env from '#start/env'
const MISTRAL_API = 'https://api.mistral.ai/v1'
// Modèle chat rapide et bon en français pour générer du texte court.
// `mistral-small-latest` est ~10x moins cher que `mistral-large` et
// largement suffisant pour 3 paragraphes de relance.
const GENERATION_MODEL = 'mistral-small-latest'
export type RelanceTone = 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
export type GenerateRelanceInput = {
/** Tonalité ciblée — guide le ton du modèle. */
tone: RelanceTone
/** Position de l'étape dans le plan (J+3, J+10…). Influence l'urgence. */
offsetDays: number
/** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */
prompt: string
/** Nom du plan parent — donne du contexte au modèle (ex. "Clients fidèles"). */
planName?: string
/** Description du plan parent — quand utiliser ce plan, ICP visé. */
planDescription?: string
}
export type GenerateRelanceOutput = {
subject: string
body: string
}
const TONE_GUIDANCE: Record<RelanceTone, string> = {
amical:
"Ton chaleureux et bienveillant, presque comme un message à un partenaire de confiance. Pas de pression. On commence par 'Bonjour' suivi du prénom si dispo, sinon du nom de l'entreprise.",
courtois:
'Ton professionnel et factuel. Poli, neutre, pas de chaleur excessive ni de menace. Standard B2B.',
ferme:
"Ton ferme et direct. Rappelle l'engagement contractuel. Reste poli mais sans formule de politesse excessive. Pas d'agressivité.",
mise_en_demeure:
"Ton formel et juridique. Mentionne explicitement 'mise en demeure', un délai de paiement (8 jours), et les conséquences légales (pénalités de retard, voie judiciaire). Reste factuel, pas émotionnel.",
}
const SYSTEM_PROMPT = `Tu rédiges des emails de relance de factures impayées en français pour une TPE-PME française.
# Règles de rédaction
- Toujours en français.
- Vouvoie systématiquement le destinataire (B2B France).
- Concis : 4 à 8 phrases maximum pour le corps.
- Une salutation au début, et termine TOUJOURS le corps par {{signature}} sur sa propre ligne. **Ne jamais réécrire le nom de l'expéditeur ni l'entreprise à la main après {{signature}}** : la variable contient déjà tout (nom + entreprise + formule de politesse choisie par l'utilisateur).
# Syntaxe des variables IMPORTANT
- Utilise UNIQUEMENT la substitution simple \`{{nom.de.variable}}\`.
- N'utilise JAMAIS la syntaxe de sections \`{{#var}}...{{/var}}\`, \`{{^var}}...{{/var}}\`, ni aucune syntaxe conditionnelle. Notre interpréteur ne fait que de la substitution simple — toute syntaxe avancée s'affichera telle quelle dans l'email final.
- Tu n'es **PAS obligé** d'utiliser toutes les variables. Choisis celles qui rendent le message naturel et utile. Mieux vaut un message simple et clair qu'un message bourré de variables.
# Variables disponibles
- {{client.name}} : raison sociale du client (toujours rempli)
- {{client.contactFirstName}} : prénom du contact (peut être vide à l'envoi — dans ce cas la variable s'efface silencieusement, donc préfère une formule qui marche dans les deux cas, ex. "Bonjour {{client.contactFirstName}}," l'absence du prénom donne juste "Bonjour ,")
- {{client.contactLastName}} : nom du contact (peut être vide)
- {{numero}} : numéro de la facture
- {{amount}} : montant TTC formaté (ex. "1 240,00 €")
- {{dueDate}} : date d'échéance (ex. "15/04/2026")
- {{issueDate}} : date d'émission
- {{daysLate}} : jours de retard (entier peut être négatif si la relance est avant échéance)
- {{user.fullName}} : nom de l'expéditeur (rarement utile dans le corps si on a déjà {{signature}})
- {{user.companyName}} : nom de l'entreprise expéditrice
- {{signature}} : bloc signature complet termine TOUJOURS le corps par cette variable
# Format de retour
JSON strict avec deux clés :
- "subject" : sujet (max 100 caractères, naturel, peut contenir {{numero}})
- "body" : corps de l'email`
/**
* Génère un email de relance via Mistral. Retourne `{ subject, body }`
* avec des placeholders Mustache prêts à être interpolés à l'envoi.
*
* Coût : ~0.0001 par appel sur `mistral-small-latest` (négligeable).
*/
export async function generateRelance(input: GenerateRelanceInput): Promise<GenerateRelanceOutput> {
const apiKey = env.get('MISTRAL_API_KEY', '')
if (!apiKey) {
throw new Error('MISTRAL_API_KEY manquante : génération IA indisponible.')
}
const offsetExplanation =
input.offsetDays < 0
? `${Math.abs(input.offsetDays)} jours **avant** l'échéance (rappel anticipé)`
: input.offsetDays === 0
? "le **jour J** de l'échéance"
: `${input.offsetDays} jours **après** l'échéance (la facture est en retard)`
const userMessage = [
'# Plan parent',
input.planName ? `Nom : ${input.planName}` : 'Nom : (non précisé)',
input.planDescription
? `Description : ${input.planDescription}`
: 'Description : (aucune)',
'',
'# Cette relance',
`Tonalité : ${input.tone}${TONE_GUIDANCE[input.tone]}`,
`Timing : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays}${offsetExplanation}.`,
'',
"# Brief de l'utilisateur",
input.prompt.trim() ||
'(aucun brief — rédige un message standard pour cette tonalité et ce timing, en restant naturel)',
].join('\n')
const res = await fetch(`${MISTRAL_API}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: GENERATION_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'relance_email',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
subject: { type: 'string' },
body: { type: 'string' },
},
required: ['subject', 'body'],
},
},
},
temperature: 0.7,
}),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Mistral génération relance → HTTP ${res.status}: ${text}`)
}
const json = (await res.json()) as {
choices?: { message?: { content?: string } }[]
}
const content = json?.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('Mistral chat: pas de content string dans la réponse')
}
const parsed = JSON.parse(content) as GenerateRelanceOutput
return {
subject: parsed.subject.slice(0, 200),
body: parsed.body.slice(0, 5000),
}
}

View File

@ -1,36 +0,0 @@
import { DateTime } from 'luxon'
import User from '#models/user'
import UserTransformer from '#transformers/user_transformer'
import env from '#start/env'
import { issueRefreshToken } from '#services/refresh_token'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Émet une AuthSession complète : access token en JSON + refresh token
* en cookie httpOnly. Utilisé par signup et login.
*
* Format de réponse aligné sur packages/shared/src/types/auth.ts :
* `{ data: { accessToken, expiresAt, user } }`
*/
export async function emitAuthSession(
user: User,
ctx: HttpContext
): Promise<{
accessToken: string
expiresAt: string
user: ReturnType<UserTransformer['toObject']>
}> {
const accessToken = await User.accessTokens.create(user)
await issueRefreshToken(user, ctx)
const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30)
const expiresAt =
accessToken.expiresAt?.toISOString() ??
DateTime.now().plus({ minutes: ttlMin }).toISO()!
return {
accessToken: accessToken.value!.release(),
expiresAt,
user: new UserTransformer(user).toObject(),
}
}

View File

@ -1,106 +0,0 @@
import { DateTime } from 'luxon'
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import { generateCheckinToken } from '#services/checkin_token'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const CHECKIN_QUEUE = 'checkins'
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Programme un check-in pour une facture.
*
* V1 : 1 check-in par facture, envoyé à `dueDate` (pile à l'échéance).
* Si dueDate est dans le passé envoie immédiat (à `now + 1min`),
* pour que les factures importées en retard reçoivent quand même un
* check-in.
*
* Le token est généré ici (plain) on retourne le plain pour permettre
* au caller de le passer dans des emails de test si besoin, mais en
* pratique seul le hash est stocké et lu via SendCheckinJob.
*
* Idempotent par invoice : si une CheckinTask `scheduled` existe déjà,
* on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling
* après changement de dueDate).
*
* En tests : la task DB est créée mais l'enqueue BullMQ est skippé
* (les tx auto-rollback laisseraient des jobs orphelins en Redis sinon).
*/
export async function scheduleCheckinForInvoice(
invoice: Invoice,
trx?: TransactionClientContract
): Promise<{ task: CheckinTask; plain: string } | null> {
// Cancel l'éventuelle CheckinTask scheduled précédente.
const existing = await CheckinTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.where('status', 'scheduled')
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
for (const t of existing) {
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
t.useTransaction(trx ?? (null as never))
t.status = 'expired'
await t.save()
}
const now = DateTime.now()
const sendAtRaw = invoice.dueDate
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw
const { plain, hashed } = generateCheckinToken()
const task = await CheckinTask.create(
{
organizationId: invoice.organizationId,
invoiceId: invoice.id,
sendAt,
tokenHash: hashed,
status: 'scheduled',
sentAt: null,
answeredAt: null,
answer: null,
},
trx ? { client: trx } : undefined
)
if (queue) {
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
await queue.add(
'send-checkin',
{ taskId: task.id, plain },
{
delay,
jobId: `checkin-${task.id}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
}
)
}
return { task, plain }
}
/**
* Annule le check-in scheduled d'une facture (appelé par mark-paid).
*/
export async function cancelCheckinForInvoice(
invoiceId: string,
trx?: TransactionClientContract
): Promise<void> {
const tasks = await CheckinTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoiceId)
.where('status', 'scheduled')
if (tasks.length === 0) return
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
for (const t of tasks) {
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
t.useTransaction(trx ?? (null as never))
t.status = 'expired'
await t.save()
}
}

View File

@ -1,16 +0,0 @@
import crypto from 'node:crypto'
/**
* Tokens check-in : 32 bytes random base64url. On stocke le hash
* SHA-256 en DB (CheckinTask.token_hash). Pas de signature HMAC : le
* token est purement opaque, sa "signature" c'est sa présence en DB.
*/
export function generateCheckinToken(): { plain: string; hashed: string } {
const plain = crypto.randomBytes(32).toString('base64url')
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
return { plain, hashed }
}
export function hashCheckinToken(plain: string): string {
return crypto.createHash('sha256').update(plain).digest('hex')
}

View File

@ -1,91 +0,0 @@
import db from '@adonisjs/lucid/services/db'
/**
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
* (V1 : pas de cache, le volume reste raisonnable). Si le perf devient un
* sujet, on cachera dans Redis avec invalidation post-mutation invoice.
*/
export type ClientStats = {
invoiceCount: number
activeInvoiceCount: number
lateInvoiceCount: number
paidInvoiceCount: number
paidLifetimeCents: number
pendingLifetimeCents: number
lastActivityAt: string | null
}
export const EMPTY_CLIENT_STATS: ClientStats = {
invoiceCount: 0,
activeInvoiceCount: 0,
lateInvoiceCount: 0,
paidInvoiceCount: 0,
paidLifetimeCents: 0,
pendingLifetimeCents: 0,
lastActivityAt: null,
}
/**
* Calcule les stats pour un ensemble de clients d'une org en une seule
* requête agrégée par client_id. Les clients sans facture reçoivent EMPTY.
*
* @returns Map clientId ClientStats
*/
export async function bulkComputeClientStats(
organizationId: string,
clientIds: string[]
): Promise<Map<string, ClientStats>> {
const map = new Map<string, ClientStats>()
for (const id of clientIds) {
map.set(id, { ...EMPTY_CLIENT_STATS })
}
if (clientIds.length === 0) return map
const today = new Date()
today.setHours(0, 0, 0, 0)
const ACTIVE = "('pending','in_relance','awaiting_user_confirmation')"
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('client_id', clientIds)
.select('client_id')
.select(db.raw('count(*)::int as invoice_count'))
.select(db.raw(`count(*) filter (where status::text in ${ACTIVE})::int as active_count`))
.select(
db.raw(
`count(*) filter (where status::text in ${ACTIVE} and due_date < ?)::int as late_count`,
[today]
)
)
.select(db.raw(`count(*) filter (where status = 'paid')::int as paid_count`))
.select(
db.raw(`coalesce(sum(amount_ttc_cents) filter (where status = 'paid'), 0)::int as paid_cents`)
)
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where status::text in ${ACTIVE}), 0)::int as pending_cents`
)
)
.select(db.raw('max(updated_at) as last_activity'))
.groupBy('client_id')
for (const r of rows) {
map.set(r.client_id, {
invoiceCount: r.invoice_count,
activeInvoiceCount: r.active_count,
lateInvoiceCount: r.late_count,
paidInvoiceCount: r.paid_count,
paidLifetimeCents: r.paid_cents,
pendingLifetimeCents: r.pending_cents,
lastActivityAt:
r.last_activity instanceof Date
? r.last_activity.toISOString()
: (r.last_activity as string | null),
})
}
return map
}

View File

@ -1,150 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import { DateTime } from 'luxon'
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
export type DashboardKpis = {
rubisCount: number
rubisThisMonth: number
// 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
hoursLiberatedThisMonth: number
encaisseCents: number
encaisseDeltaCents: number
dsoDays: number
dsoDeltaDays: number
factureToRelance: number
factureInRelance: number
factureNewToday: number
miseEnDemeurePending: number
monthlyGoalProgress: number
// Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
percentile?: number
}
function startOfMonth(d: DateTime): Date {
return d.startOf('month').toJSDate()
}
function startOfDay(d: DateTime): Date {
return d.startOf('day').toJSDate()
}
/**
* Calcule les KPIs dashboard pour une organisation.
*
* V1 implémentation simple sans cache. Quelques metrics avancés
* (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
* Le contrat reste stable côté SPA.
*/
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
const now = DateTime.now()
const monthStart = startOfMonth(now)
const todayStart = startOfDay(now)
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
const org = await Organization.findOrFail(organizationId)
// Counts par statut + factures récentes
const counts = (await db
.from('invoices')
.where('organization_id', organizationId)
.select(
db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
)
.first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
// Sommes d'encaissement (paid_at) ce mois et le précédent
const paidStats = (await db
.from('invoices')
.where('organization_id', organizationId)
.where('status', 'paid')
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
[monthStart]
),
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
[prevMonthStart, monthStart]
),
db.raw(
`coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
[prevMonthStart, monthStart]
)
)
.first()) as
| {
this_month: number
prev_month: number
rubis_this_month: number
dso_this_month: number
dso_prev_month: number
}
| undefined
const encaisseCents = paidStats?.this_month ?? 0
const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
const rubisThisMonth = paidStats?.rubis_this_month ?? 0
const dsoDays = paidStats?.dso_this_month ?? 0
const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
return {
rubisCount: org.rubisCount,
rubisThisMonth,
hoursLiberatedThisMonth: rubisThisMonth * 10,
encaisseCents,
encaisseDeltaCents,
dsoDays,
dsoDeltaDays,
factureToRelance: counts?.to_relance ?? 0,
factureInRelance: counts?.in_relance ?? 0,
factureNewToday: counts?.new_today ?? 0,
// Mise en demeure pending — sera calculé quand RelanceTask est branché
// (count des steps requires_manual_validation programmées). Pour V1 : 0.
miseEnDemeurePending: 0,
// Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
// mensuel arbitraire). À paramétrer plus tard.
monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
percentile: undefined,
}
}
/**
* Top des clients en retard (top 5 par défaut).
* Compte les factures actives dont due_date est dépassée, agrégées par client.
*/
export async function topLatePayers(
organizationId: string,
limit = 5
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
const today = startOfDay(DateTime.now())
const rows = await db
.from('invoices')
.innerJoin('clients', 'clients.id', 'invoices.client_id')
.where('invoices.organization_id', organizationId)
.whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
.where('invoices.due_date', '<', today)
.groupBy('clients.id', 'clients.name')
.select('clients.id as client_id', 'clients.name as name')
.select(db.raw('count(*)::int as late_invoices_count'))
.orderBy('late_invoices_count', 'desc')
.limit(limit)
return rows.map((r) => ({
clientId: r.client_id,
name: r.name,
lateInvoicesCount: r.late_invoices_count,
}))
}

View File

@ -1,205 +0,0 @@
/**
* Source de vérité des 4 plans pré-fournis (cf. CLAUDE.md Périmètre V1).
* Dupliqués dans chaque organisation à la création (signup) V1 mono-tenant
* mais l'isolation est totale, on peut éditer le plan d'une org sans toucher
* aux autres.
*
* Les valeurs (cadences, tons, sujets) doivent rester alignées sur le seed
* MSW (apps/web/src/mocks/seed.ts SEED_PLANS) tant que les deux coexistent.
*/
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
type DefaultStep = {
order: number
offsetDays: number
tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
subject: string
body: string
requiresManualValidation: boolean
}
type DefaultPlan = {
slug: string
name: string
description: string
steps: DefaultStep[]
}
export const DEFAULT_PLANS: DefaultPlan[] = [
{
slug: 'standard-30j',
name: 'Standard B2B',
description:
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
steps: [
{
order: 0,
offsetDays: 3,
tone: 'amical',
subject: 'Petit rappel — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 10,
tone: 'courtois',
subject: 'Relance — facture {{numero}} en retard',
body:
"Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 25,
tone: 'ferme',
subject: 'Mise en demeure — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
requiresManualValidation: true,
},
],
},
{
slug: 'rapide-15j',
name: 'Rapide',
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'amical',
subject: 'Facture {{numero}} échue',
body: 'Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 7,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 15,
tone: 'ferme',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
{
slug: 'patient-60j',
name: 'Patient',
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
steps: [
{
order: 0,
offsetDays: 15,
tone: 'amical',
subject: 'Facture {{numero}}',
body: 'Bonjour, simple rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 30,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'Merci de régulariser dans les meilleurs délais.\n\n{{signature}}',
requiresManualValidation: false,
},
],
},
{
slug: 'ferme-7j',
name: 'Ferme',
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'courtois',
subject: 'Facture {{numero}}',
body: 'Premier rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 5,
tone: 'ferme',
subject: 'Relance ferme {{numero}}',
body: 'Le règlement est attendu sous 48h.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 10,
tone: 'mise_en_demeure',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
]
/**
* Provisionne les 4 plans par défaut pour une organisation fraîchement créée.
* Idempotent : si l'org a déjà un plan avec un slug, on n'écrase pas.
*
* À appeler dans la transaction de signup.
*/
export async function provisionDefaultPlans(
organizationId: string,
trx: TransactionClientContract
): Promise<Plan[]> {
const existing = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.whereIn(
'slug',
DEFAULT_PLANS.map((p) => p.slug)
)
.select('slug')
const existingSlugs = new Set(existing.map((p) => p.slug))
const created: Plan[] = []
for (const tpl of DEFAULT_PLANS) {
if (existingSlugs.has(tpl.slug)) continue
const plan = await Plan.create(
{
organizationId,
slug: tpl.slug,
name: tpl.name,
description: tpl.description,
isDefault: true,
},
{ client: trx }
)
await PlanStep.createMany(
tpl.steps.map((s) => ({
planId: plan.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
created.push(plan)
}
return created
}

View File

@ -1,160 +0,0 @@
import db from '@adonisjs/lucid/services/db'
import ImportBatch from '#models/import_batch'
import ImportDraft from '#models/import_draft'
import Client from '#models/client'
import Plan from '#models/plan'
import { getOcrProvider } from '#services/ocr/index'
import type { OcrResult } from '#services/ocr/ocr_provider'
export type DraftFields = {
clientId: string | null
clientName: string
clientEmail: string | null
numero: string
amountTtcCents: number
issueDate: string
dueDate: string
planId: string | null
}
export type DraftConfidence = Partial<{
clientId: number
clientName: number
clientEmail: number
numero: number
amountTtcCents: number
issueDate: number
dueDate: number
planId: number
}>
/**
* Une "source" de draft : un filename + (optionnellement) une storageKey
* MinIO du PDF stocké. Mock OCR ignore storageKey, Mistral l'exige.
*/
export type ImportSource = {
filename: string
storageKey: string | null
}
/**
* Compose `extracted` + `confidence` à partir du résultat OCR. Tente un
* match client immédiat (case-insensitive) pour pré-remplir `clientId`.
*/
async function buildDraftFromOcr(
organizationId: string,
ocr: OcrResult,
defaultPlanId: string | null
): Promise<{ extracted: DraftFields; confidence: DraftConfidence }> {
const matchedClient = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', ocr.fields.clientName.value)
.first()
return {
extracted: {
clientId: matchedClient?.id ?? null,
clientName: matchedClient?.name ?? ocr.fields.clientName.value,
clientEmail: matchedClient?.email ?? ocr.fields.clientEmail.value,
numero: ocr.fields.numero.value,
amountTtcCents: ocr.fields.amountTtcCents.value,
issueDate: ocr.fields.issueDate.value,
dueDate: ocr.fields.dueDate.value,
planId: defaultPlanId,
},
confidence: {
clientName: matchedClient ? 1 : ocr.fields.clientName.confidence,
clientEmail: ocr.fields.clientEmail.confidence,
numero: ocr.fields.numero.confidence,
amountTtcCents: ocr.fields.amountTtcCents.confidence,
issueDate: ocr.fields.issueDate.confidence,
dueDate: ocr.fields.dueDate.confidence,
planId: 1,
},
}
}
/**
* Crée un batch + N drafts à partir de N sources (filename + storageKey).
* Le provider OCR (mock ou mistral) est résolu à l'intérieur.
*
* - Mock : storageKey=null OK, extraction depuis filename
* - Mistral : storageKey requis, extraction depuis le PDF stocké
*/
export async function createImportBatch(
organizationId: string,
sources: ImportSource[]
): Promise<ImportBatch> {
const ocr = getOcrProvider()
// Plan par défaut = premier is_default de l'org (provisionné au signup).
const defaultPlan = await Plan.query()
.where('organization_id', organizationId)
.where('is_default', true)
.orderBy('name', 'asc')
.first()
// OCR fait HORS transaction (calls réseau lents, on ne tient pas de lock
// PG pendant). Si l'OCR échoue, l'erreur remonte avant le INSERT.
type DraftPayload = {
filename: string
storageKey: string | null
extracted: DraftFields
edited: DraftFields
confidence: DraftConfidence
}
const drafts: DraftPayload[] = []
for (const src of sources) {
const result = await ocr.extract(src)
const { extracted, confidence } = await buildDraftFromOcr(
organizationId,
result,
defaultPlan?.id ?? null
)
drafts.push({
filename: src.filename,
storageKey: src.storageKey,
extracted,
edited: { ...extracted },
confidence,
})
}
return db.transaction(async (trx) => {
const batch = await ImportBatch.create({ organizationId }, { client: trx })
for (const d of drafts) {
await ImportDraft.create(
{
batchId: batch.id,
filename: d.filename,
pdfStorageKey: d.storageKey,
extracted: d.extracted,
edited: d.edited,
confidence: d.confidence,
status: 'pending',
invoiceId: null,
},
{ client: trx }
)
}
await batch.load('drafts')
return batch
})
}
/**
* Wrapper compat : V1 mock JSON `{filenames}` sources avec storageKey null.
* @deprecated Préférer `createImportBatch` avec sources explicites.
*/
export async function createImportBatchFromFilenames(
organizationId: string,
filenames: string[]
): Promise<ImportBatch> {
return createImportBatch(
organizationId,
filenames.map((filename) => ({ filename, storageKey: null }))
)
}

View File

@ -1,154 +0,0 @@
import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
import type User from '#models/user'
import type Organization from '#models/organization'
type RelancePayload = {
invoice: Invoice
client: Client
step: PlanStep
user: User | null
organization?: Organization | null
}
/**
* Construit l'objet `vars` interpolé dans subject/body. Exposé pour
* permettre la preview côté contrôleur (wizard de création de plan)
* avec les mêmes variables que ce qui sera réellement envoyé.
*
* Variables disponibles :
* - {{client.name}}, {{client.email}}
* - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides)
* - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}}
* - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance)
* - {{user.fullName}}, {{user.companyName}}
* - {{signature}}
*/
export function buildRelanceVars({
invoice,
client,
user,
organization,
}: {
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
organization?: Pick<Organization, 'name'> | null
}) {
const dueDate = invoice.dueDate.toJSDate()
// Jours de retard arrondis à l'entier (UTC pour cohérence).
const daysLate = Math.floor(
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
return {
client: {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName ?? '',
contactLastName: client.contactLastName ?? '',
},
user: {
fullName: user?.fullName ?? '',
companyName: organization?.name ?? '',
},
numero: invoice.numero,
amount: formatAmountFr(invoice.amountTtcCents),
dueDate: formatDateFr(dueDate),
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
daysLate: String(daysLate),
signature: user?.signature ?? user?.fullName ?? '',
}
}
/**
* Envoie un email de relance à un client à partir d'un step.
* Le subject/body du step contiennent des placeholders Mustache-like
* qu'on interpole avant l'envoi (cf. `buildRelanceVars`).
*
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
* `resend` en prod).
*/
export async function sendRelanceEmail({
invoice,
client,
step,
user,
organization,
}: RelancePayload) {
const vars = buildRelanceVars({ invoice, client, user, organization })
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
.text(body)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
}
type CheckinPayload = {
invoice: Invoice
client: Client
user: User
paidUrl: string
pendingUrl: string
}
/**
* Envoie le check-in à l'**utilisateur** (pas au client). Lui demande
* si la facture a é payée, avec 2 liens publics qui modifient l'état
* côté API et redirigent ensuite vers le SPA.
*
* Texte brut V1. Un template HTML viendra quand on aura figé le look
* graphique (cf. ADR-021).
*/
export async function sendCheckinEmail({
invoice,
client,
user,
paidUrl,
pendingUrl,
}: CheckinPayload) {
const subject = `Facture ${invoice.numero} — payée par ${client.name} ?`
const body = `Bonjour ${user.fullName ?? ''},
La facture ${invoice.numero} (${formatAmountFr(invoice.amountTtcCents)}) émise pour ${client.name}
arrive à échéance aujourd'hui (${formatDateFr(invoice.dueDate.toJSDate())}).
Avant que Rubis n'envoie la première relance, dites-nous vous en êtes :
J'ai é payé(e), pas besoin de relancer :
${paidUrl}
Toujours en attente, lance la relance comme prévu :
${pendingUrl}
Ces liens expirent dans 24h.
Merci,
L'équipe Rubis`
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.text(body)
})
}

View File

@ -1,20 +0,0 @@
import env from '#start/env'
import type { OcrProvider } from '#services/ocr/ocr_provider'
import { MockOcrProvider } from '#services/ocr/mock_ocr_provider'
import { MistralOcrProvider } from '#services/ocr/mistral_ocr_provider'
/**
* Résout l'implémentation OCR à utiliser selon OCR_PROVIDER.
*
* - `mock` (default) : MockOcrProvider, données plausibles depuis filename.
* Compatible avec /invoices/upload en mode JSON `{filenames}`.
* - `mistral` : MistralOcrProvider. Nécessite un PDF stocké (multipart
* upload) + MISTRAL_API_KEY. Pas compatible avec le mode JSON.
*/
export function getOcrProvider(): OcrProvider {
const provider = env.get('OCR_PROVIDER', 'mock')
if (provider === 'mistral') {
return new MistralOcrProvider()
}
return new MockOcrProvider()
}

View File

@ -1,213 +0,0 @@
import drive from '@adonisjs/drive/services/main'
import env from '#start/env'
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
const MISTRAL_API = 'https://api.mistral.ai/v1'
// Modèle OCR dédié de Mistral — extrait le texte structuré d'un doc.
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.
Règles :
- amountTtcCents : montant TTC en centimes (entier). Pas le HT.
- issueDate / dueDate : ISO 8601 datetime UTC à 09:00 (ex. "2026-04-15T09:00:00.000Z").
- clientEmail : null si absent ou illisible (pas d'invention).
- numero : tel qu'imprimé sur la facture.
- Si un champ est ambigu, mets une confiance basse (0.30.6).`
/**
* Provider OCR Mistral. Pipeline en 2 étapes :
* 1. POST /v1/ocr avec le PDF en data URI base64 markdown structuré
* 2. POST /v1/chat/completions avec le markdown + json_schema strict
* extraction typée des champs
*
* Nécessite un PDF réel (storageKey non null). Pour le dev sans PDF,
* utiliser OCR_PROVIDER=mock.
*/
export class MistralOcrProvider implements OcrProvider {
private apiKey: string
constructor() {
const key = env.get('MISTRAL_API_KEY', '')
if (!key) {
throw new Error(
'MISTRAL_API_KEY manquante. Posez la dans .env ou bascule OCR_PROVIDER=mock.'
)
}
this.apiKey = key
}
async extract(input: {
storageKey: string | null
filename: string
}): Promise<OcrResult> {
if (!input.storageKey) {
throw new Error(
`MistralOcrProvider exige un PDF stocké (storageKey). Filename "${input.filename}" reçu sans storageKey — utiliser OCR_PROVIDER=mock pour les uploads sans fichier réel.`
)
}
// 1. Télécharge le fichier depuis Drive (MinIO en dev) puis encode en base64.
const buffer = await this.downloadAsBuffer(input.storageKey)
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: this.documentPayload(dataUri, mimeType),
})
const markdown = (ocrJson?.pages ?? [])
.map((p: { markdown?: string }) => p.markdown ?? '')
.join('\n\n')
.trim()
if (!markdown) {
throw new Error("Mistral OCR n'a retourné aucun texte exploitable")
}
// 3. Extraction structurée via chat avec json_schema strict.
const extracted = await this.extractFields(markdown)
return {
fields: {
clientName: { value: extracted.clientName, confidence: extracted._conf.clientName },
clientEmail: { value: extracted.clientEmail, confidence: extracted._conf.clientEmail },
numero: { value: extracted.numero, confidence: extracted._conf.numero },
amountTtcCents: {
value: extracted.amountTtcCents,
confidence: extracted._conf.amountTtcCents,
},
issueDate: { value: extracted.issueDate, confidence: extracted._conf.issueDate },
dueDate: { value: extracted.dueDate, confidence: extracted._conf.dueDate },
},
rawProviderResponse: { ocr: ocrJson, extracted },
}
}
private async downloadAsBuffer(storageKey: string): Promise<Buffer> {
const arr = await drive.use().getArrayBuffer(storageKey)
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',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Mistral ${path} → HTTP ${res.status}: ${text}`)
}
return res.json()
}
private async extractFields(markdown: string): Promise<{
clientName: string
clientEmail: string | null
numero: string
amountTtcCents: number
issueDate: string
dueDate: string
_conf: Record<string, number>
}> {
const json = await this.postJson('/chat/completions', {
model: EXTRACTION_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: markdown },
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'invoice_fields',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
clientName: { type: 'string' },
clientEmail: { type: ['string', 'null'] },
numero: { type: 'string' },
amountTtcCents: { type: 'integer' },
issueDate: { type: 'string' },
dueDate: { type: 'string' },
_conf: {
type: 'object',
additionalProperties: false,
properties: {
clientName: { type: 'number' },
clientEmail: { type: 'number' },
numero: { type: 'number' },
amountTtcCents: { type: 'number' },
issueDate: { type: 'number' },
dueDate: { type: 'number' },
},
required: [
'clientName',
'clientEmail',
'numero',
'amountTtcCents',
'issueDate',
'dueDate',
],
},
},
required: [
'clientName',
'clientEmail',
'numero',
'amountTtcCents',
'issueDate',
'dueDate',
'_conf',
],
},
},
},
temperature: 0,
})
const content = json?.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('Mistral chat: pas de content string dans la réponse')
}
return JSON.parse(content)
}
}

View File

@ -1,84 +0,0 @@
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
const SAMPLE_CLIENT_NAMES = [
'Boulangerie Martin SARL',
'Atelier Durand',
'Cabinet Rousseau',
'Garage Lemoine',
'Studio Lefèvre',
'Pharmacie Bertrand',
'Imprimerie Moreau',
] as const
function rand<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!
}
function randomAmountCents(): number {
// entre 80 € et 8 000 €, multiple de 50 cts pour rester crédible
return Math.floor(((Math.random() * 7920 + 80) * 100) / 50) * 50
}
function numeroFromFilename(filename: string): string {
const match = filename.match(/(\d{2,5})/u)
const yr = new Date().getFullYear()
const seq = match?.[1] ?? Math.floor(Math.random() * 9000 + 1000).toString()
return `F-${yr}-${seq.padStart(4, '0')}`
}
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
d.setHours(9, 0, 0, 0)
return d.toISOString()
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/sarl|sa|sas/giu, '')
.replace(/[^a-z]+/giu, '-')
.replace(/^-+|-+$/gu, '')
}
/**
* Implémentation OCR mock : génère des champs plausibles depuis le filename
* + injecte volontairement quelques confidences basses (~30 %) pour signaler
* "champ douteux" dans l'UI de review.
*
* Aucun appel réseau, aucun PDF téléchargé. Quand Mistral arrive, on swap
* cette classe via le container Adonis sans toucher au reste.
*/
export class MockOcrProvider implements OcrProvider {
async extract(input: {
storageKey: string | null
filename: string
}): Promise<OcrResult> {
const clientName = rand(SAMPLE_CLIENT_NAMES)
// 30 % de chance d'avoir un email douteux (low confidence) — déclenche
// la pastille "à vérifier" dans la UI de review.
const emailLowConf = Math.random() < 0.3
const email = emailLowConf ? null : `compta@${slugify(clientName)}.fr`
return {
fields: {
clientName: { value: clientName, confidence: 0.95 },
clientEmail: {
value: email,
confidence: emailLowConf ? 0.42 : 0.88,
},
numero: { value: numeroFromFilename(input.filename), confidence: 0.97 },
amountTtcCents: { value: randomAmountCents(), confidence: 0.93 },
issueDate: {
value: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
confidence: 0.9,
},
dueDate: {
value: isoDaysFromNow(15 + Math.floor(Math.random() * 20)),
confidence: emailLowConf ? 0.65 : 0.92,
},
},
rawProviderResponse: { provider: 'mock', filename: input.filename },
}
}
}

View File

@ -1,33 +0,0 @@
/**
* Interface OCR abstraction switchable (cf. backend.md §11.1).
*
* Implémentations :
* - MockOcrProvider : retour plausible depuis le filename, pour les démos
* et le dev sans Mistral. C'est le default en V1 (OCR_PROVIDER=mock).
* - MistralOcrProvider : à venir (ADR-020), appel API Mistral avec PDF
* téléchargé depuis MinIO.
*/
export interface OcrProvider {
extract(input: { storageKey: string | null; filename: string }): Promise<OcrResult>
}
export type OcrFieldName =
| 'clientName'
| 'clientEmail'
| 'numero'
| 'amountTtcCents'
| 'issueDate'
| 'dueDate'
export type OcrResult = {
fields: {
clientName: { value: string; confidence: number }
clientEmail: { value: string | null; confidence: number }
numero: { value: string; confidence: number }
amountTtcCents: { value: number; confidence: number }
issueDate: { value: string; confidence: number } // ISO 8601
dueDate: { value: string; confidence: number }
}
/** Trace brute du provider — utile pour debug / re-process / audit. */
rawProviderResponse?: unknown
}

View File

@ -1,60 +0,0 @@
import { Queue, Worker, type Processor } from 'bullmq'
import { redisConnection, queueNames, type QueueName } from '#config/queue'
import logger from '@adonisjs/core/services/logger'
/**
* Wrappers BullMQ partagés. Chaque queue a 1 instance Queue (producer)
* et N workers (consumers) avec le bon handler.
*
* V1 : on garde tout en mémoire process workers et HTTP partagent le
* même Node. Quand le volume justifie le coût, on extrait les workers
* dans un Deployment K3s séparé (cf. backend.md §13.4).
*/
const queues = new Map<QueueName, Queue>()
const workers: Worker[] = []
export function getQueue(name: QueueName): Queue {
let q = queues.get(name)
if (!q) {
q = new Queue(name, { connection: redisConnection })
queues.set(name, q)
}
return q
}
export type JobHandler<T = unknown> = Processor<T>
/**
* Enregistre un Worker BullMQ sur une queue. Démarre tout de suite.
* Appelé par start/queue.ts au boot pour câbler les handlers.
*/
export function registerWorker<T = unknown>(name: QueueName, handler: JobHandler<T>): Worker {
const worker = new Worker<T>(name, handler, {
connection: redisConnection,
concurrency: 5,
})
worker.on('failed', (job, err) => {
logger.error({ err, queue: name, jobId: job?.id }, 'job failed')
})
worker.on('completed', (job) => {
logger.info({ queue: name, jobId: job.id }, 'job completed')
})
workers.push(worker)
return worker
}
/**
* Stoppe proprement tous les workers + queues. Appelé au shutdown du
* process via Adonis terminating hook.
*/
export async function shutdownQueue(): Promise<void> {
await Promise.all(workers.map((w) => w.close()))
await Promise.all(Array.from(queues.values()).map((q) => q.close()))
}
/**
* Liste des noms de queue (re-export du config pour ne pas exposer la
* connection Redis ailleurs dans l'app).
*/
export const QUEUES = queueNames

View File

@ -1,151 +0,0 @@
import crypto from 'node:crypto'
import { DateTime } from 'luxon'
import RefreshToken from '#models/refresh_token'
import User from '#models/user'
import env from '#start/env'
import type { HttpContext } from '@adonisjs/core/http'
export const REFRESH_COOKIE_NAME = 'rubis_refresh'
/**
* Génère un token plain (32 bytes random base64url ~43 chars), retourne
* { plain, hashed } pour ne stocker que le hashed côté DB.
*
* SHA-256 suffit : le token est un opaque random non humain, pas un mot
* de passe pas besoin de bcrypt/argon (contrairement aux passwords).
*/
function generateToken(): { plain: string; hashed: string } {
const plain = crypto.randomBytes(32).toString('base64url')
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
return { plain, hashed }
}
function hashToken(plain: string): string {
return crypto.createHash('sha256').update(plain).digest('hex')
}
function ttlDays(): number {
return env.get('REFRESH_TOKEN_TTL_DAYS', 30)
}
/**
* Pose le cookie httpOnly avec le token plain. Le SPA ne peut pas le lire
* en JS c'est ce qui le protège du XSS, contrairement à localStorage.
*
* `path: /api/v1/auth` : le browser n'envoie le cookie qu'aux endpoints
* d'auth, pas sur chaque requête API. Réduit la surface d'attaque CSRF.
*/
function setRefreshCookie(ctx: HttpContext, plain: string) {
const maxAgeSeconds = ttlDays() * 24 * 60 * 60
ctx.response.cookie(REFRESH_COOKIE_NAME, plain, {
httpOnly: true,
secure: env.get('COOKIE_SECURE', false),
sameSite: 'strict',
path: '/api/v1/auth',
domain: env.get('COOKIE_DOMAIN') || undefined,
maxAge: maxAgeSeconds,
})
}
function clearRefreshCookie(ctx: HttpContext) {
ctx.response.clearCookie(REFRESH_COOKIE_NAME, {
path: '/api/v1/auth',
domain: env.get('COOKIE_DOMAIN') || undefined,
})
}
/**
* Crée un refresh token pour un user et pose le cookie correspondant.
* Appelé après signup et login.
*/
export async function issueRefreshToken(
user: User,
ctx: HttpContext
): Promise<{ token: RefreshToken; plain: string }> {
const { plain, hashed } = generateToken()
const token = await RefreshToken.create({
userId: user.id,
hashedToken: hashed,
expiresAt: DateTime.now().plus({ days: ttlDays() }),
lastUsedAt: null,
revokedAt: null,
ipAddress: ctx.request.ip(),
userAgent: ctx.request.header('user-agent') ?? null,
})
setRefreshCookie(ctx, plain)
return { token, plain }
}
/**
* Valide le cookie reçu et révoque l'ancien token. Retourne le user
* authentifié le contrôleur appelle ensuite `issueRefreshToken` (via
* emitAuthSession) pour poser un nouveau cookie. Rotation complète.
*
* Si le user envoie un token déjà révoqué, on suppose un vol potentiel
* et on révoque TOUS les tokens actifs du user (panic mode).
*/
export async function consumeRefreshToken(
ctx: HttpContext
): Promise<{ user: User } | { errorCode: 'no_session' | 'session_expired' }> {
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
if (!cookie) return { errorCode: 'no_session' }
const hashed = hashToken(cookie)
const stored = await RefreshToken.query().where('hashed_token', hashed).first()
if (!stored) {
clearRefreshCookie(ctx)
return { errorCode: 'session_expired' }
}
// Token déjà révoqué = signal de vol potentiel : on coupe tout pour
// l'user concerné. Le vrai propriétaire devra se re-logger.
if (stored.revokedAt) {
await revokeAllForUser(stored.userId)
clearRefreshCookie(ctx)
return { errorCode: 'session_expired' }
}
if (stored.expiresAt < DateTime.now()) {
stored.revokedAt = DateTime.now()
await stored.save()
clearRefreshCookie(ctx)
return { errorCode: 'session_expired' }
}
stored.revokedAt = DateTime.now()
stored.lastUsedAt = DateTime.now()
await stored.save()
const user = await User.findOrFail(stored.userId)
return { user }
}
/**
* Révoque le token courant (utilisé par /account/logout).
* Pas de panic l'user demande explicitement la déconnexion.
*/
export async function revokeCurrentRefreshToken(ctx: HttpContext): Promise<void> {
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
if (cookie) {
const hashed = hashToken(cookie)
await RefreshToken.query()
.where('hashed_token', hashed)
.whereNull('revoked_at')
.update({ revoked_at: DateTime.now().toSQL() })
}
clearRefreshCookie(ctx)
}
/**
* Révoque tous les tokens d'un user (panic mode si vol détecté, ou
* appelable par "déconnecter toutes mes sessions").
*/
export async function revokeAllForUser(userId: string): Promise<void> {
await RefreshToken.query()
.where('user_id', userId)
.whereNull('revoked_at')
.update({ revoked_at: DateTime.now().toSQL() })
}

View File

@ -1,157 +0,0 @@
import { DateTime } from 'luxon'
import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
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'
const RELANCE_QUEUE = 'relances'
/**
* En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
* l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
* orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
* Redis live pour tourner les tests.
*/
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Programme toutes les relances d'une facture selon son plan.
*
* - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays
* - Crée une RelanceTask `scheduled`
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
*
* 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
* change de plan).
*/
export async function scheduleRelancesForInvoice(
invoice: Invoice,
trx?: TransactionClientContract
): Promise<RelanceTask[]> {
if (!invoice.planId) return []
const plan = await Plan.query(trx ? { client: trx } : undefined)
.where('id', invoice.planId)
.preload('steps', (q) => q.orderBy('order', 'asc'))
.first()
if (!plan) return []
const alreadyActive = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.whereIn('status', ['scheduled', 'sent'])
if (alreadyActive.length > 0) {
return alreadyActive
}
// Cancel les tasks scheduled existantes (re-scheduling après changement
// de plan ou de dueDate).
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.where('status', 'scheduled')
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
for (const t of existing) {
if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {
// Ignore — le job peut déjà être consommé.
})
}
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 steps) {
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
const sendAt =
catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays
? catchUpAnchor.sendAt.plus({
days: step.offsetDays - catchUpAnchor.offsetDays,
})
: sendAtRaw
const task = await RelanceTask.create(
{
organizationId: invoice.organizationId,
invoiceId: invoice.id,
planStepId: step.id,
sendAt,
status: 'scheduled',
sentAt: null,
queueJobId: null,
},
trx ? { client: trx } : undefined
)
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
const job = queue
? await queue.add(
'send-relance',
{ taskId: task.id },
{
delay,
// Idempotency : un seul job actif par task.
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
jobId: `relance-${task.id}`,
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
// backoff (cf. backend.md §13.2).
attempts: 5,
backoff: { type: 'exponential', delay: 30_000 },
}
)
: null
task.queueJobId = job?.id ?? null
await task.save()
created.push(task)
}
return created
}
/**
* Annule toutes les relances futures d'une facture (appelé quand on
* mark-paid ou cancel une invoice). Les tasks déjà `sent` restent
* intactes c'est de l'historique.
*/
export async function cancelFutureRelances(
invoiceId: string,
trx?: TransactionClientContract
): Promise<void> {
const tasks = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoiceId)
.where('status', 'scheduled')
if (tasks.length === 0) return
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
for (const t of tasks) {
if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {})
}
t.useTransaction(trx ?? (null as never))
t.status = 'cancelled'
await t.save()
}
}

View File

@ -1,61 +0,0 @@
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Client from '#models/client'
export type ResolveClientInput = {
clientId?: string | null
clientName: string
clientEmail?: string | null
}
export type ResolveClientResult =
| { client: Client; created: boolean }
| { errorCode: 'client_email_required' }
/**
* Résolution client à la création de facture / validation d'import OCR.
*
* Priorité (mêmes règles côté API que côté MSW) :
* 1. `clientId` fourni + existant dans l'org utilise tel quel.
* 2. Match par nom (case-insensitive) sur les clients de l'org.
* 3. Création à la volée `clientEmail` REQUIS, sinon
* `{ errorCode: 'client_email_required' }`.
*
* Le contrôleur appelant transforme l'erreur en HTTP 422 avec le code stable.
*/
export async function resolveClient(
organizationId: string,
fields: ResolveClientInput,
trx: TransactionClientContract
): Promise<ResolveClientResult> {
if (fields.clientId) {
const c = await Client.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.clientId)
.first()
if (c) return { client: c, created: false }
}
const matched = await Client.query({ client: trx })
.where('organization_id', organizationId)
.whereILike('name', fields.clientName)
.first()
if (matched) return { client: matched, created: false }
if (!fields.clientEmail) {
return { errorCode: 'client_email_required' }
}
const created = await Client.create(
{
organizationId,
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
siret: null,
notes: null,
},
{ client: trx }
)
return { client: created, created: true }
}

View File

@ -1,37 +0,0 @@
/**
* Mini interpolateur Mustache-like utilisé pour les sujets/corps des
* emails de relance. Supporte les chemins pointés (`{{client.name}}`).
*
* Volontairement simple : pas d'expressions, pas de conditions, pas de
* boucles. Si un chemin manque, retourne "" (silencieux l'utilisateur
* verra un blanc, pas une exception).
*/
export function renderTemplate(template: string, vars: Record<string, unknown>): string {
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
const parts = path.split('.')
let val: unknown = vars
for (const p of parts) {
if (val == null || typeof val !== 'object') return ''
val = (val as Record<string, unknown>)[p]
}
return val == null ? '' : String(val)
})
}
/**
* Helper d'affichage montant : 12400 "124,00 €".
*/
export function formatAmountFr(cents: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(cents / 100)
}
/**
* Helper d'affichage date : ISO/Date "15/04/2026".
*/
export function formatDateFr(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
return new Intl.DateTimeFormat('fr-FR').format(date)
}

View File

@ -1,22 +0,0 @@
import type Client from '#models/client'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class ClientTransformer extends BaseTransformer<Client> {
toObject() {
const c = this.resource
return {
id: c.id,
organizationId: c.organizationId,
name: c.name,
email: c.email,
contactFirstName: c.contactFirstName,
contactLastName: c.contactLastName,
phone: c.phone,
address: c.address,
siret: c.siret,
notes: c.notes,
createdAt: c.createdAt.toISO()!,
updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!,
}
}
}

View File

@ -1,33 +0,0 @@
import type ImportBatch from '#models/import_batch'
import type ImportDraft from '#models/import_draft'
import { BaseTransformer } from '@adonisjs/core/transformers'
function serializeDraft(d: ImportDraft) {
return {
id: d.id,
filename: d.filename,
pdfStorageKey: d.pdfStorageKey,
extracted: d.extracted,
edited: d.edited,
confidence: d.confidence,
status: d.status,
invoiceId: d.invoiceId,
createdAt: d.createdAt.toISO()!,
updatedAt: d.updatedAt?.toISO() ?? d.createdAt.toISO()!,
}
}
export default class ImportBatchTransformer extends BaseTransformer<ImportBatch> {
toObject() {
const b = this.resource
return {
id: b.id,
organizationId: b.organizationId,
drafts: (b.drafts ?? []).map(serializeDraft),
createdAt: b.createdAt.toISO()!,
updatedAt: b.updatedAt?.toISO() ?? b.createdAt.toISO()!,
}
}
}
export { serializeDraft }

View File

@ -1,30 +0,0 @@
import type Invoice from '#models/invoice'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class InvoiceTransformer extends BaseTransformer<Invoice> {
toObject() {
const i = this.resource
return {
id: i.id,
organizationId: i.organizationId,
clientId: i.clientId,
// Le SPA affiche `clientName` dans la liste — c'est lu depuis la
// relation préchargée, sinon vide. La V1 MSW dénormalisait ce champ
// dans la table invoice, on préfère le préchargement côté API.
clientName: i.client?.name ?? '',
numero: i.numero,
amountTtcCents: i.amountTtcCents,
issueDate: i.issueDate.toISO()!,
dueDate: i.dueDate.toISO()!,
status: i.status,
planId: i.planId,
planName: i.plan?.name ?? null,
pdfStorageKey: i.pdfStorageKey,
notes: i.notes,
rubisEarned: i.rubisEarned,
paidAt: i.paidAt?.toISO() ?? null,
createdAt: i.createdAt.toISO()!,
updatedAt: i.updatedAt?.toISO() ?? i.createdAt.toISO()!,
}
}
}

View File

@ -1,19 +0,0 @@
import type Organization from '#models/organization'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class OrganizationTransformer extends BaseTransformer<Organization> {
toObject() {
const o = this.resource
return {
// UUID natif (cf. CLAUDE.md → Conventions techniques).
id: o.id,
name: o.name,
siret: o.siret,
monthlyVolumeBucket: o.monthlyVolumeBucket,
rubisCount: o.rubisCount,
onboardingCompletedAt: o.onboardingCompletedAt?.toISO() ?? null,
createdAt: o.createdAt.toISO()!,
updatedAt: o.updatedAt?.toISO() ?? o.createdAt.toISO()!,
}
}
}

View File

@ -1,34 +0,0 @@
import type Plan from '#models/plan'
import type PlanStep from '#models/plan_step'
import { BaseTransformer } from '@adonisjs/core/transformers'
function serializeStep(s: PlanStep) {
return {
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
}
}
export default class PlanTransformer extends BaseTransformer<Plan> {
toObject() {
const p = this.resource
// p.steps doit être préchargé par le controller (preload('steps'))
const steps = (p.steps ?? []).slice().sort((a, b) => a.order - b.order)
return {
id: p.id,
organizationId: p.organizationId,
slug: p.slug,
name: p.name,
description: p.description,
isDefault: p.isDefault,
steps: steps.map(serializeStep),
createdAt: p.createdAt.toISO()!,
updatedAt: p.updatedAt?.toISO() ?? p.createdAt.toISO()!,
}
}
}

View File

@ -1,19 +0,0 @@
import type User from '#models/user'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
const u = this.resource
return {
// id et organizationId sont des UUID (cf. CLAUDE.md → Conventions techniques).
id: u.id,
email: u.email,
fullName: u.fullName,
organizationId: u.organizationId,
signature: u.signature,
initials: u.initials,
createdAt: u.createdAt.toISO()!,
updatedAt: u.updatedAt?.toISO() ?? u.createdAt.toISO()!,
}
}
}

View File

@ -1,41 +0,0 @@
import vine from '@vinejs/vine'
const name = () => vine.string().minLength(2).maxLength(120)
const email = () => vine.string().email().maxLength(254)
// SIRET = 14 chiffres exactement (cf. INSEE).
const siret = () => vine.string().regex(/^\d{14}$/)
const phone = () => vine.string().maxLength(40)
const address = () => vine.string().maxLength(500)
const notes = () => vine.string().maxLength(2000)
// Prénom/nom du contact dédié — utilisés comme variables dans les templates
// custom ({{client.contactFirstName}}). Optionnels.
const contactName = () => vine.string().minLength(1).maxLength(80)
/**
* Validator pour POST /clients. Email **requis** : sans email, Rubis ne
* peut pas relancer (pivot produit, cf. CLAUDE.md Principes).
*/
export const createClientValidator = vine.create({
name: name(),
email: email(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
notes: notes().nullable().optional(),
})
/**
* Validator pour PATCH /clients/:id. Tous les champs optionnels.
*/
export const updateClientValidator = vine.create({
name: name().optional(),
email: email().optional(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
notes: notes().nullable().optional(),
})

View File

@ -1,29 +0,0 @@
import vine from '@vinejs/vine'
/**
* POST /invoices/upload V1 mock.
*
* Accepte un tableau de filenames (pas de fichiers réels). Quand on
* branchera Mistral + MinIO, on switchera sur multipart `files[]` avec
* upload effectif des PDFs. Le contrat côté SPA reste le même.
*/
export const uploadValidator = vine.create({
filenames: vine.array(vine.string().minLength(1).maxLength(500)).minLength(1).maxLength(20),
})
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate.
*
* Le SPA envoie les `edited` finaux (peut différer de `extracted` si
* l'utilisateur a corrigé). On les normalise puis on crée l'invoice.
*/
export const validateDraftValidator = vine.create({
clientId: vine.string().uuid().nullable(),
clientName: vine.string().minLength(1).maxLength(120),
clientEmail: vine.string().email().nullable(),
numero: vine.string().minLength(1).maxLength(50),
amountTtcCents: vine.number().min(1),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
planId: vine.string().uuid().nullable(),
})

View File

@ -1,42 +0,0 @@
import vine from '@vinejs/vine'
const INVOICE_STATUSES = [
'pending',
'awaiting_user_confirmation',
'in_relance',
'paid',
'litigation',
'cancelled',
] as const
/**
* Filtres GET /invoices?status=&q=&clientId=&page=
*/
export const listInvoicesValidator = vine.create({
status: vine.enum([...INVOICE_STATUSES, 'all'] as const).optional(),
q: vine.string().maxLength(120).optional(),
clientId: vine.string().uuid().optional(),
page: vine.number().min(1).optional(),
})
/**
* POST /invoices saisie manuelle.
*
* Le SPA peut envoyer :
* - clientId d'un client existant (combobox a sélectionné une fiche), OU
* - clientName seul on tente de matcher par nom, sinon création à la
* volée mais alors clientEmail est REQUIS (pivot produit, cf. Client).
*
* On ne peut pas exprimer "email requis si pas de match" en Vine pur, donc
* c'est le contrôleur qui retourne 422 `client_email_required` si besoin.
*/
export const createInvoiceValidator = vine.create({
clientId: vine.string().uuid().optional(),
clientName: vine.string().minLength(2).maxLength(120),
clientEmail: vine.string().email().nullable().optional(),
numero: vine.string().minLength(1).maxLength(50),
amountTtcCents: vine.number().min(1),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
planId: vine.string().uuid().nullable().optional(),
})

View File

@ -1,14 +0,0 @@
import vine from '@vinejs/vine'
const MONTHLY_VOLUME_BUCKETS = ['moins-10', '10-50', '50-100', '100-200', 'plus-200'] as const
/**
* Validator pour PATCH /organizations/me. Tous les champs optionnels :
* l'utilisateur peut compléter au fil de l'onboarding.
*/
export const updateOrganizationValidator = vine.create({
name: vine.string().minLength(2).maxLength(120).optional(),
// SIRET = 14 chiffres exactement, sinon null pour réinitialiser.
siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
monthlyVolumeBucket: vine.enum(MONTHLY_VOLUME_BUCKETS).nullable().optional(),
})

View File

@ -1,36 +0,0 @@
import vine from '@vinejs/vine'
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
const planStep = vine.object({
// id optionnel : présent si on édite une étape existante, absent pour
// une création (le contrôleur le générera).
id: vine.string().optional(),
order: vine.number().min(0),
// Plage : -30 (rappel avant échéance) à 180 jours (gros retards).
offsetDays: vine.number().min(-30).max(180),
tone: vine.enum(RELANCE_TONES),
subject: vine.string().minLength(1).maxLength(200),
body: vine.string().minLength(1).maxLength(5000),
requiresManualValidation: vine.boolean(),
})
/**
* Validator pour PATCH /plans/:slug. Tous les champs optionnels l'éditeur
* front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie.
*/
export const updatePlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80).optional(),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10).optional(),
})
/**
* Validator pour POST /plans création d'un plan custom.
* Le slug est généré côté contrôleur depuis le name.
*/
export const createPlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10),
})

View File

@ -1,35 +0,0 @@
import vine from '@vinejs/vine'
/**
* Shared rules for email and password.
*/
const email = () => vine.string().email().maxLength(254)
const password = () => vine.string().minLength(8).maxLength(72)
/**
* Validator pour /auth/signup. Contrat aligné sur le SPA (Zod
* `registerSchema` dans packages/shared). Pas de passwordConfirmation
* côté API : la confirmation visuelle est une affaire de formulaire.
*/
export const signupValidator = vine.create({
email: email().unique({ table: 'users', column: 'email' }),
password: password(),
fullName: vine.string().minLength(2).maxLength(120),
})
/**
* Validator pour /auth/login.
*/
export const loginValidator = vine.create({
email: email(),
password: vine.string(),
})
/**
* Validator pour /account/profile (PATCH). Tous les champs optionnels.
*/
export const updateProfileValidator = vine.create({
fullName: vine.string().minLength(2).maxLength(120).optional(),
email: email().optional(),
signature: vine.string().maxLength(500).optional(),
})

View File

@ -1,47 +0,0 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

View File

@ -1,45 +0,0 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

View File

@ -1,62 +0,0 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

View File

@ -1,61 +0,0 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
/**
* Envoie un email de test via le mailer courant (typiquement Resend)
* pour valider la conf SPF/DKIM/clé API sans passer par toute la chaîne
* facture job BullMQ.
*
* node ace send:test-email arthur@example.com
* node ace send:test-email arthur@example.com --reply-to=patron@tpe.fr
*/
export default class SendTestEmail extends BaseCommand {
static commandName = 'send:test-email'
static description = 'Envoie un email de test via le mailer configuré (Resend en prod)'
static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Adresse destinataire' })
declare to: string
@flags.string({ description: 'Adresse de reply-to (optionnelle)' })
declare replyTo?: string
async run() {
const driver = env.get('MAIL_DRIVER', 'smtp')
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
this.logger.info(`Driver: ${driver}`)
this.logger.info(`From: ${fromName} <${fromAddress}>`)
this.logger.info(`To: ${this.to}`)
if (this.replyTo) this.logger.info(`ReplyTo: ${this.replyTo}`)
const mailer = mail.use(driver)
const response = await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(this.to)
.subject('[Rubis] Test d\'envoi via Resend')
.text(
`Bonjour,\n\n` +
`Ceci est un email de test envoyé depuis Rubis Sur l'Ongle.\n` +
`Si vous recevez ce message, la conf Resend (SPF/DKIM/API key) est OK.\n\n` +
`Driver utilisé : ${driver}\n` +
`Date : ${new Date().toISOString()}\n\n` +
`— L'équipe Rubis`
)
if (this.replyTo) m.replyTo(this.replyTo)
})
this.logger.success('Email envoyé')
// Resend renvoie un messageId dans la réponse — utile pour retrouver
// le log dans le dashboard.
if (response?.messageId) {
this.logger.info(`messageId: ${response.messageId}`)
}
}
}

View File

@ -1,93 +0,0 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app key is used for encrypting cookies, generating signed URLs,
* and by the "encryption" module.
*
* The encryption module will fail to decrypt data if the key is lost or
* changed. Therefore it is recommended to keep the app key secure.
*/
export const appKey = env.get('APP_KEY')
/**
* The app URL can be used in various places where you want to create absolute
* URLs to your application. For example, when sending emails, images should
* use absolute URLs.
*/
export const appUrl = env.get('APP_URL')
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
*/
generateRequestId: true,
/**
* Allow HTTP method spoofing via the "_method" form/query parameter.
* This lets HTML forms target PUT/PATCH/DELETE routes while still
* submitting with POST.
*/
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding.
*/
redirect: {
/**
* When enabled, all redirects automatically carry over the current
* request's query string parameters to the redirect destination.
* Use withQs(false) to opt out for a specific redirect.
*/
forwardQueryString: true,
},
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
/**
* Restrict the cookie to a specific domain.
* Keep empty to use the current host.
*/
domain: '',
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Default lifetime for cookies managed by the HTTP layer.
*/
maxAge: '2h',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
})

View File

@ -1,50 +0,0 @@
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
/**
* Default guard used when no guard is explicitly specified.
*/
default: 'api',
guards: {
/**
* Token-based guard for stateless API authentication.
*/
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
/**
* Session-based guard for browser authentication.
*/
web: sessionGuard({
/**
* Enable persistent login using remember-me tokens.
*/
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}

View File

@ -1,78 +0,0 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* Parse request bodies for these HTTP methods.
* Keep this aligned with methods that receive payloads in your routes.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser.
*/
form: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the form parser.
*/
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser.
*/
json: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the JSON parser.
*/
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Automatically process uploaded files into the system tmp directory.
*/
autoProcess: true,
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Routes where multipart processing is handled manually.
*/
processManually: [],
/**
* Maximum accepted payload size for multipart requests.
*/
limit: '20mb',
/**
* Content types handled by the multipart parser.
*/
types: ['multipart/form-data'],
},
})
export default bodyParserConfig

View File

@ -1,50 +0,0 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
/**
* Enable or disable CORS handling globally.
*/
enabled: true,
/**
* In development, allow every origin to simplify local front/backend setup.
* In production, keep an explicit allowlist (empty by default, so no
* cross-origin browser access is allowed until configured).
*/
origin: app.inDev ? true : [],
/**
* HTTP methods accepted for cross-origin requests.
*/
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Reflect request headers by default. Use a string array to restrict
* allowed headers.
*/
headers: true,
/**
* Response headers exposed to the browser.
*/
exposeHeaders: [],
/**
* Allow cookies/authorization headers on cross-origin requests.
*/
credentials: true,
/**
* Cache CORS preflight response for N seconds.
*/
maxAge: 90,
})
export default corsConfig

View File

@ -1,51 +0,0 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'
import env from '#start/env'
const dbConfig = defineConfig({
/**
* Postgres en dev/prod. SQLite reste accessible via DB_CONNECTION=sqlite
* pour les tests rapides ou un usage offline.
*/
connection: env.get('DB_CONNECTION', 'postgres'),
connections: {
postgres: {
client: 'pg',
connection: {
host: env.get('PG_HOST'),
port: env.get('PG_PORT'),
user: env.get('PG_USER'),
password: env.get('PG_PASSWORD'),
database: env.get('PG_DB_NAME'),
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
schemaGeneration: {
enabled: true,
rulesPaths: ['./database/schema_rules.js'],
},
debug: app.inDev,
},
sqlite: {
client: 'better-sqlite3',
connection: {
filename: app.tmpPath('db.sqlite3'),
},
useNullAsDefault: true,
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
schemaGeneration: {
enabled: true,
rulesPaths: ['./database/schema_rules.js'],
},
},
},
})
export default dbConfig

View File

@ -1,39 +0,0 @@
import { defineConfig, services } from '@adonisjs/drive'
import type { InferDriveDisks } from '@adonisjs/drive/types'
import env from '#start/env'
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK', 's3'),
/**
* Stockage local (filesystem) utilisé en fallback si MinIO indisponible.
* Bucket par défaut : storage/uploads (ignoré par git).
*/
services: {
fs: services.fs({
location: 'storage/uploads',
visibility: 'private',
}),
/**
* MinIO via le driver S3 (S3-compatible).
*/
s3: services.s3({
credentials: {
accessKeyId: env.get('S3_ACCESS_KEY', ''),
secretAccessKey: env.get('S3_SECRET_KEY', ''),
},
endpoint: env.get('S3_ENDPOINT'),
region: env.get('S3_REGION', 'fr-par'),
bucket: env.get('S3_BUCKET', 'rubis-invoices'),
forcePathStyle: env.get('S3_FORCE_PATH_STYLE', true),
visibility: 'private',
}),
},
})
export default driveConfig
declare module '@adonisjs/drive/types' {
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
}

View File

@ -1,34 +0,0 @@
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
const encryptionConfig = defineConfig({
/**
* Default encryption driver used by the application.
*/
default: 'gcm',
list: {
gcm: drivers.aes256gcm({
/**
* Keys used for encryption/decryption.
* First key encrypts, all keys are tried for decryption.
*/
keys: [env.get('APP_KEY')],
/**
* Stable identifier for this driver.
*/
id: 'gcm',
}),
},
})
export default encryptionConfig
/**
* Inferring types for the list of encryptors you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
}

View File

@ -1,75 +0,0 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
/**
* Hashing configuration.
*
* This starter uses Node.js scrypt under the hood.
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
*/
const hashConfig = defineConfig({
/**
* Default hasher used by the application.
*/
default: 'scrypt',
list: {
/**
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
*/
scrypt: drivers.scrypt({
/**
* Work factor (Node alias: N / cost).
* Higher values increase security and CPU+memory usage.
*
* Tuning guideline:
* - Start with 16384.
* - Increase gradually (for example 32768) and benchmark login/signup latency.
* - Keep values practical for your slowest production machine.
*
* Node constraint: value must be a power of two greater than 1.
*/
cost: 16384,
/**
* Block size (Node alias: r / blockSize).
* Increases memory and CPU linearly.
*
* Tuning guideline:
* - Keep 8 unless you have a measured reason to change it.
* - Raise only with benchmark data, because memory usage grows quickly.
*/
blockSize: 8,
/**
* Parallelization (Node alias: p / parallelization).
* Controls how many independent computations are performed.
*
* Tuning guideline:
* - Keep 1 for most applications.
* - Increase only after load testing if your infrastructure benefits from it.
*/
parallelization: 1,
/**
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
* Hashing throws if the estimated memory usage is above this limit.
* Node documents the check as approximately: 128 * N * r > maxmem.
*
* Tuning guideline:
* - Keep this aligned with your cost/blockSize choices.
* - Increase carefully on memory-constrained environments.
*/
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}

View File

@ -1,31 +0,0 @@
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/limiter'
import type { InferLimiters } from '@adonisjs/limiter/types'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
/**
* Redis store to save rate limiting data inside a
* redis database.
*
* It is recommended to use a separate database for
* the limiter connection.
*/
redis: stores.redis({}),
/**
* Memory store could be used during
* testing
*/
memory: stores.memory({})
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}

View File

@ -1,51 +0,0 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
/**
* Default logger name used by ctx.logger and app logger calls.
*/
default: 'app',
loggers: {
app: {
/**
* Toggle this logger on/off.
*/
enabled: true,
/**
* Logger name shown in log records.
*/
name: env.get('APP_NAME'),
/**
* Minimum level to output (trace, debug, info, warn, error, fatal).
*/
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
/**
* Configure where logs are written.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}

View File

@ -1,46 +0,0 @@
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'
import type { InferMailers } from '@adonisjs/mail/types'
const mailConfig = defineConfig({
default: env.get('MAIL_DRIVER', 'smtp'),
from: {
address: env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'),
name: env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"),
},
/**
* Variables partagées par tous les templates Edge (logo, URL de base).
*/
globals: {
brandName: "Rubis Sur l'Ongle",
appUrl: env.get('APP_URL'),
},
mailers: {
/**
* SMTP Mailpit en dev (catch-all sur localhost:1025), n'importe quel
* relais SMTP en prod si on ne veut pas de provider tiers.
*/
smtp: transports.smtp({
host: env.get('SMTP_HOST', 'localhost'),
port: env.get('SMTP_PORT', 1025),
// Auth optionnelle — pas requise pour Mailpit
}),
/**
* Resend provider transactionnel par défaut en prod (cf. ADR-021).
*/
resend: transports.resend({
key: env.get('RESEND_API_KEY', ''),
baseUrl: 'https://api.resend.com',
}),
},
})
export default mailConfig
declare module '@adonisjs/mail/types' {
export interface MailersList extends InferMailers<typeof mailConfig> {}
}

View File

@ -1,29 +0,0 @@
import env from '#start/env'
import { type RedisOptions } from 'ioredis'
/**
* Connexion Redis partagée pour BullMQ. On garde un objet d'options
* (et pas une instance) parce que BullMQ instancie ses propres clients
* pour chaque queue/worker.
*/
export const redisConnection: RedisOptions = {
host: env.get('REDIS_HOST', 'localhost'),
port: env.get('REDIS_PORT', 6379),
password: env.get('REDIS_PASSWORD') || undefined,
// Requis par BullMQ pour les blocking commands.
maxRetriesPerRequest: null,
}
/**
* Liste des queues. La concurrence est appliquée côté worker.
* Ajouter une queue ici ajouter un Worker correspondant dans #start/queue.ts.
*/
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis'] as const
export type QueueName = (typeof queueNames)[number]
export const queueConcurrency: Record<QueueName, number> = {
ocr: 2,
relances: 5,
checkins: 5,
kpis: 1,
}

View File

@ -1,78 +0,0 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
/**
* Enable or disable session support globally.
*/
enabled: true,
/**
* Cookie name storing the session identifier.
*/
cookieName: 'adonis-session',
/**
* When set to true, the session id cookie will be deleted
* once the user closes the browser.
*/
clearWithBrowser: false,
/**
* Define how long to keep the session data alive without
* any activity.
*/
age: '2h',
/**
* Configuration for session cookie and the
* cookie store.
*/
cookie: {
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
/**
* The store to use. Make sure to validate the environment
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
/**
* List of configured stores. Refer documentation to see
* list of available stores and their config.
*/
stores: {
/**
* Store session data inside encrypted cookies.
*/
cookie: stores.cookie(),
/**
* Store session data inside the configured database.
*/
database: stores.database(),
},
})
export default sessionConfig

View File

@ -1,95 +0,0 @@
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
/**
* Configure CSP policies for your app. Refer documentation
* to learn more.
*/
csp: {
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
/**
* Per-resource CSP directives.
*/
directives: {},
/**
* Report violations without blocking resources.
*/
reportOnly: false,
},
/**
* Configure CSRF protection options. Refer documentation
* to learn more.
*/
csrf: {
/**
* Enable CSRF token verification for state-changing requests.
*/
enabled: false,
/**
* Route patterns to exclude from CSRF checks.
* Useful for external webhooks or API endpoints.
*/
exceptRoutes: [],
/**
* Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.
*/
enableXsrfCookie: true,
/**
* HTTP methods protected by CSRF validation.
*/
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
/**
* Control how your website should be embedded inside
* iframes.
*/
xFrame: {
/**
* Enable the X-Frame-Options header.
*/
enabled: true,
/**
* Block all framing attempts. Default value is DENY.
*/
action: 'DENY',
},
/**
* Force browser to always use HTTPS.
*/
hsts: {
/**
* Enable the Strict-Transport-Security header.
*/
enabled: true,
/**
* HSTS policy duration remembered by browsers.
*/
maxAge: '180 days',
},
/**
* Disable browsers from sniffing content types and rely only
* on the response content-type header.
*/
contentTypeSniffing: {
/**
* Enable X-Content-Type-Options: nosniff.
*/
enabled: true,
},
})
export default shieldConfig

Some files were not shown because too many files have changed in this diff Show More