# Guide d'implémentation — Backend

> Version : 0.2 · Dernière maj : 2026-05-09
> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage), ADR-024 (Sentry).

> ⚠️ **Note de cohérence (audit 2026-05-09)** : ce guide a été rédigé en phase de planification. Plusieurs claims ont divergé du code livré. Pour la **liste exhaustive des routes**, lire **`apps/api/start/routes.ts`** comme source de vérité (~80 routes : auth, factures, clients, plans, billing Stripe, blog admin/public, demo, AI, uploads, KPIs). Les modèles vivants sont dans `apps/api/app/models/`, les services dans `apps/api/app/services/`. Les sections ci-dessous sont valides **dans leur esprit** mais détaillent parfois des choix pré-livraison qui ont évolué.

Ce document est le **guide pratique d'implémentation de l'API**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes, snippets, conventions.

**À lire avant** :

- `/CLAUDE.md` — contexte top-level
- `/docs/produit.md` — flows utilisateur, IN/OUT V1
- `/docs/tech/architecture.md` — vue d'ensemble du système
- `/docs/tech/frontend.md` — guide d'implémentation du SPA
- `/packages/shared/src/` — types et schemas Zod partagés (statuts, tons, plans, billing)
- `/apps/api/start/routes.ts` — **contrat API actuel (source de vérité)**
- `/apps/api/app/services/` — logique métier (billing, posts, blog_uploads, default_plans, import_batch, etc.)

---

## 1. Vue d'ensemble

L'API (`apps/api/`) est un **AdonisJS v7** en TypeScript, qui sert :

- **JSON-only** sur `/api/v1/*` (pas de Inertia, pas de Hypermedia — le SPA est un consommateur séparé)
- **Auth Bearer** stateless via `@adonisjs/auth` access_tokens (cf. ADR-017), avec un refresh token cookie httpOnly géré custom par-dessus
- **Type-sharing** via `packages/shared` (Zod schemas + types TS), consommé en workspace symlink par `apps/web` et `apps/landing`. *Note historique : la doc évoquait Tuyau pour générer un client TS typé — `@tuyau/core` est dans les deps mais n'est pas utilisé en pratique côté SPA, qui consomme l'API via un client `fetch()` minimaliste dans `apps/web/src/lib/api.ts`.*
- **PostgreSQL** comme base relationnelle (cf. ADR-016, instance LXC Proxmox existante, IP `10.10.10.3`)
- **MinIO** pour les pièces jointes (cf. ADR-018, namespace K3s `minio`, bucket `rubis-prod-invoices`)
- **OCR** : Mistral (`OCR_PROVIDER=mistral`)
- **Email outbound** : Resend (sub-domaine `send.rubis.pro`)
- **Email inbound** : OVH MX (`contact@rubis.pro`)
- **Background jobs** : BullMQ (Redis), workers inline dans le pod API (pas de pod worker séparé)
- **Billing** : Stripe (checkout, customer portal, webhook `/billing/webhook`)
- **Error tracking** : Sentry (cf. ADR-024)

Le scaffold initial a été créé via `pnpm create adonisjs@latest -- apps/api --kit=api --pkg=pnpm`, kit `api`. Ça nous a déjà donné :

- Auth `access_tokens` configurée (`config/auth.ts`)
- Tuyau core installé (`@tuyau/core`) + script `node ace tuyau:generate`
- Routes auth de base : `POST /auth/signup`, `POST /auth/login`, `POST /account/logout`, `GET /account/profile`
- Lucid + Vine + CORS + Shield + Session

Tout le reste (org, clients, factures, plans, jobs, OCR, email) est à construire par-dessus.

---

## 2. Stack interne

| Couche | Choix | Rôle |
|---|---|---|
| Framework | `@adonisjs/core` v7 | HTTP, IoC, providers, ace CLI |
| ORM | `@adonisjs/lucid` | PG, migrations, query builder, relations |
| Auth | `@adonisjs/auth` (access_tokens) | Bearer tokens, middleware `auth()` |
| Authz | `@adonisjs/bouncer` | Policies pour les permissions (V1 mono-user, prêt V2 multi-user) |
| Validation | `@vinejs/vine` | Validateurs typés natifs Adonis, mappés sur les schemas Zod de `packages/shared` |
| Mail | `@adonisjs/mail` | Templates + provider switchable (Resend / Postmark / SES) |
| Queue | BullMQ direct + ioredis | Jobs différés (envoi relance, envoi confirmation, envoi remerciement paiement). Pas de wrapper Adonis. Code dans `app/services/queue.ts` |
| Cache / queue backend | Redis | Backend de BullMQ + cache des KPIs dashboard |
| Rate-limit | `@adonisjs/limiter` | 5 req/min sur `/auth/*`, 10/h sur upload |
| Tests | `@japa/runner` + `@japa/api-client` | Tests d'intégration HTTP |
| Type-sharing front | `packages/shared` (workspace) | Zod schemas + types TS (statuts, tons, plans, billing) — consommé par `apps/web` et `apps/landing` |
| HTTP client | `@adonisjs/limiter` + `ky` (côté SPA) | — |
| Storage | `@adonisjs/drive` (S3 driver MinIO) | Abstraction stockage PDFs |

### Dépendances installées (cf. `apps/api/package.json`)

Stack actuelle (V1 livrée) :

```jsonc
// apps/api/package.json (extrait)
"@adonisjs/auth", "@adonisjs/bouncer", "@adonisjs/mail",
"@adonisjs/limiter", "@adonisjs/drive", "@adonisjs/lucid",
"bullmq", "ioredis",                           // queues directes, pas de wrapper Adonis
"@aws-sdk/client-s3",                          // MinIO via driver S3
"resend",                                      // email outbound
"@mistralai/mistralai",                        // OCR
"stripe",                                      // billing
"@anthropic-ai/sdk",                           // génération IA blog
"@sentry/node",                                // ADR-024
```

Pas de BullMQ direct (sans wrapper Adonis), pas de `@tuyau/server` (le `@tuyau/core` historique n'est plus utilisé côté SPA).

---

## 3. Repo layout (apps/api)

Reflète les conventions Adonis 7 + un découpage `services/` pour la logique métier hors HTTP.

```
apps/api/
├── app/
│   ├── controllers/                # HTTP only — pas de logique métier
│   │   ├── auth/
│   │   │   ├── access_tokens_controller.ts   # POST /auth/login + DELETE
│   │   │   ├── new_account_controller.ts     # POST /auth/signup
│   │   │   └── refresh_controller.ts         # POST /auth/refresh — V1 custom
│   │   ├── account/
│   │   │   ├── profile_controller.ts         # GET + PATCH /account/profile
│   │   │   └── logout_controller.ts          # POST /account/logout
│   │   ├── organizations_controller.ts        # GET + PATCH /organizations/me
│   │   ├── clients_controller.ts              # CRUD /clients
│   │   ├── plans_controller.ts                # CRUD /plans
│   │   ├── invoices_controller.ts             # CRUD /invoices + actions
│   │   ├── invoices_upload_controller.ts      # POST /invoices/upload
│   │   ├── import_batches_controller.ts       # /invoices/import-batch/*
│   │   └── dashboard_controller.ts            # /dashboard/*
│   ├── models/                     # Lucid models, 1 par entité
│   │   ├── user.ts
│   │   ├── organization.ts
│   │   ├── client.ts
│   │   ├── invoice.ts
│   │   ├── plan.ts
│   │   ├── plan_step.ts
│   │   ├── relance_task.ts
│   │   ├── checkin_task.ts
│   │   ├── activity_event.ts
│   │   ├── import_batch.ts
│   │   └── import_draft.ts
│   ├── services/                   # Logique métier réutilisable
│   │   ├── ocr/
│   │   │   ├── ocr_provider.ts     # Interface
│   │   │   ├── mindee_provider.ts  # Implémentation
│   │   │   └── tesseract_provider.ts
│   │   ├── mail/
│   │   │   └── mail_dispatcher.ts  # Wrapper @adonisjs/mail
│   │   ├── relance_scheduler.ts    # Programme les RelanceTasks d'une facture
│   │   ├── checkin_dispatcher.ts   # Génère les tokens + envoie les check-ins
│   │   ├── rubis_calculator.ts     # 1 rubis = 10 min, recalcul org.rubisCount
│   │   └── activity_recorder.ts    # Crée des ActivityEvent
│   ├── jobs/                       # BullMQ jobs
│   │   ├── process_ocr_job.ts
│   │   ├── send_relance_job.ts
│   │   ├── send_checkin_job.ts
│   │   └── recompute_kpis_job.ts
│   ├── mails/                      # @adonisjs/mail templates
│   │   ├── relance_amical_mail.ts
│   │   ├── relance_courtois_mail.ts
│   │   ├── relance_ferme_mail.ts
│   │   ├── mise_en_demeure_mail.ts
│   │   └── checkin_mail.ts
│   ├── policies/                   # Bouncer
│   │   ├── invoice_policy.ts       # canView / canEdit / canDelete
│   │   └── plan_policy.ts
│   ├── validators/                 # Vine schemas (miroir packages/shared)
│   │   ├── auth.ts
│   │   ├── client.ts
│   │   ├── invoice.ts
│   │   └── plan.ts
│   ├── middleware/
│   │   ├── auth_middleware.ts      # déjà scaffold
│   │   ├── force_json_response_middleware.ts
│   │   └── rate_limiter_middleware.ts
│   ├── exceptions/
│   │   └── handler.ts              # Convertit les exceptions en JSON `{ errors: [...] }`
│   └── transformers/               # Sérialisation pour les réponses
│       ├── client_transformer.ts
│       ├── invoice_transformer.ts
│       └── plan_transformer.ts
├── config/
│   ├── auth.ts                     # déjà scaffold (access_tokens)
│   ├── database.ts                 # PG en V1
│   ├── drive.ts                    # MinIO via S3 driver
│   ├── mail.ts                     # Resend ou autre
│   ├── queue.ts                    # BullMQ + Redis
│   ├── limiter.ts                  # Rate limiting
│   └── shield.ts                   # CSP, CORS allowlist
├── start/
│   ├── routes.ts                   # routes par domaine, importées
│   ├── routes/
│   │   ├── auth.ts
│   │   ├── account.ts
│   │   ├── clients.ts
│   │   ├── plans.ts
│   │   ├── invoices.ts
│   │   └── dashboard.ts
│   └── kernel.ts
├── database/
│   ├── migrations/
│   │   ├── XXXX_create_users_table.ts          # déjà scaffold
│   │   ├── XXXX_create_access_tokens_table.ts  # déjà scaffold
│   │   ├── XXXX_create_refresh_tokens_table.ts # à créer
│   │   ├── XXXX_create_organizations_table.ts
│   │   ├── XXXX_create_clients_table.ts
│   │   ├── XXXX_create_plans_table.ts
│   │   ├── XXXX_create_plan_steps_table.ts
│   │   ├── XXXX_create_invoices_table.ts
│   │   ├── XXXX_create_relance_tasks_table.ts
│   │   ├── XXXX_create_checkin_tasks_table.ts
│   │   ├── XXXX_create_import_batches_table.ts
│   │   ├── XXXX_create_import_drafts_table.ts
│   │   └── XXXX_create_activity_events_table.ts
│   ├── seeders/
│   │   └── (pas de seeders Adonis : les 4 plans pré-fournis vivent dans
│   │       app/services/default_plans.ts et sont insérés à la création de
│   │       chaque organisation. Le mode démo est servi par
│   │       app/services/demo_simulator.ts, pas par un seeder.)
│   └── factories.ts                      # Factories Lucid (mono-fichier, pas de dossier — V1)
├── tests/
│   ├── functional/                       # Tests HTTP via @japa/api-client
│   │   ├── auth.spec.ts
│   │   ├── invoices.spec.ts
│   │   └── ...
│   └── unit/
└── package.json
```

---

## 4. Domain models

Le modèle est **scopé par `organization_id` partout** — V1 mono-user mais multi-tenant ready dès le schéma. Tous les Lucid queries doivent filtrer sur `organization_id` (en pratique : un middleware ou un scope global qui injecte ce filtre).

### 4.0 Identifiants — UUID partout

**Convention dure** : toutes les PK et FK applicatives sont des UUID (PG `uuid` avec default `gen_random_uuid()`). **Pas d'`increments`/`serial`**, y compris pour les tables techniques (`auth_access_tokens`, `refresh_tokens`, `import_batches`, etc.).

```ts
// migration
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.uuid('organization_id').notNullable().references('id').inTable('organizations').onDelete('CASCADE')
```

```ts
// model — id est typé string, le schema generator le déduit du type uuid
@column({ isPrimary: true })
declare id: string
```

Côté transformers, on expose l'UUID tel quel (pas de cast `String(id)`).

**Pourquoi** :
1. **Anti-énumération** : `/invoices/42` révèle qu'on en a au moins 42, ce qui peut leak des volumes business.
2. **Fédération multi-tenant** : pas besoin d'un namespace par tenant pour éviter les collisions, l'UUID est globalement unique.
3. **Génération côté client** : permet d'optimiste-créer un objet (UUID généré côté SPA) avant le round-trip API.
4. **Pas de surprise sur les exports** : un dump CSV qui sort des `id=1,2,3` est un bug latent.

### 4.1 User

```ts
// app/models/user.ts
import { BaseModel, column, belongsTo } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { DateTime } from 'luxon'
import Organization from '#models/organization'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
  uids: ['email'],
  passwordColumnName: 'password',
})

export default class User extends compose(BaseModel, AuthFinder) {
  @column({ isPrimary: true })
  declare id: string

  @column()
  declare email: string

  @column({ serializeAs: null })
  declare password: string

  @column({ columnName: 'full_name' })
  declare fullName: string

  @column({ columnName: 'organization_id' })
  declare organizationId: string

  @column()
  declare signature: string | null

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime

  @belongsTo(() => Organization)
  declare organization: BelongsTo<typeof Organization>

  static accessTokens = DbAccessTokensProvider.forModel(User, {
    expiresIn: '30 mins',
  })
}
```

### 4.2 Organization

```ts
@column() declare name: string
@column() declare siret: string | null
@column({ columnName: 'monthly_volume_bucket' }) declare monthlyVolumeBucket: string | null
@column({ columnName: 'rubis_count' }) declare rubisCount: number
@column.dateTime({ columnName: 'onboarding_completed_at' })
declare onboardingCompletedAt: DateTime | null
```

> **Pattern** : `onboardingCompletedAt` côté serveur remplace l'astuce `signature !== null` du SPA. Une fois set, le SPA ne renvoie plus l'utilisateur dans le wizard.

### 4.3 Client

```ts
@column() declare name: string
@column() declare email: string                    // REQUIS — pivot du produit
@column() declare phone: string | null
@column() declare address: string | null
@column() declare siret: string | null             // 14 chiffres si fourni
@column() declare notes: string | null

@hasMany(() => Invoice) declare invoices: HasMany<typeof Invoice>
```

### 4.4 Invoice

```ts
@column() declare numero: string
@column({ columnName: 'amount_ttc_cents' }) declare amountTtcCents: number
@column.dateTime({ columnName: 'issue_date' }) declare issueDate: DateTime
@column.dateTime({ columnName: 'due_date' }) declare dueDate: DateTime
@column() declare status: InvoiceStatus
@column({ columnName: 'plan_id' }) declare planId: string | null
@column({ columnName: 'pdf_storage_key' }) declare pdfStorageKey: string | null
@column() declare notes: string | null
@column({ columnName: 'rubis_earned' }) declare rubisEarned: number
@column({ columnName: 'paid_at' }) declare paidAt: DateTime | null

@belongsTo(() => Client) declare client: BelongsTo<typeof Client>
@belongsTo(() => Plan) declare plan: BelongsTo<typeof Plan>
@hasMany(() => RelanceTask) declare relanceTasks: HasMany<typeof RelanceTask>
```

**InvoiceStatus** (enum DB + TS, miroir `packages/shared/constants`) :

```
pending · in_relance · awaiting_user_confirmation · paid · litigation · cancelled
```

### 4.5 Plan + PlanStep

```ts
// Plan
@column() declare slug: string | null      // null pour les plans custom
@column() declare name: string
@column() declare description: string
@column({ columnName: 'is_default' }) declare isDefault: boolean

@hasMany(() => PlanStep, { foreignKey: 'planId' }) declare steps: HasMany<typeof PlanStep>

// PlanStep
@column() declare order: number
@column({ columnName: 'offset_days' }) declare offsetDays: number
@column() declare tone: RelanceTone
@column() declare subject: string
@column() declare body: string
@column({ columnName: 'requires_manual_validation' })
declare requiresManualValidation: boolean
```

### 4.6 RelanceTask + CheckinTask

```ts
// RelanceTask : une étape de relance programmée pour une facture
@column({ columnName: 'invoice_id' }) declare invoiceId: string
@column({ columnName: 'plan_step_id' }) declare planStepId: string
@column.dateTime({ columnName: 'send_at' }) declare sendAt: DateTime
@column() declare status: 'scheduled' | 'sent' | 'cancelled' | 'failed'
@column({ columnName: 'sent_at' }) declare sentAt: DateTime | null
@column({ columnName: 'queue_job_id' }) declare queueJobId: string | null

// CheckinTask : email envoyé à l'utilisateur (pas au client) avant chaque relance
@column({ columnName: 'invoice_id' }) declare invoiceId: string
@column.dateTime({ columnName: 'send_at' }) declare sendAt: DateTime
@column() declare token: string                  // signé, TTL 24h
@column() declare status: 'scheduled' | 'sent' | 'answered' | 'expired'
```

### 4.7 ImportBatch + ImportDraft

Éphémères : créés à l'upload OCR, supprimés une fois tous les drafts validés ou skippés.

```ts
// ImportBatch
@column({ columnName: 'organization_id' }) declare organizationId: string
@hasMany(() => ImportDraft) declare drafts: HasMany<typeof ImportDraft>

// ImportDraft
@column() declare filename: string
@column({ columnName: 'pdf_storage_key' }) declare pdfStorageKey: string
@column() declare extracted: object              // jsonb
@column() declare edited: object                 // jsonb
@column() declare confidence: object             // jsonb
@column() declare status: 'pending' | 'validated' | 'skipped'
@column({ columnName: 'invoice_id' }) declare invoiceId: string | null
```

### 4.8 ActivityEvent

Pour le journal d'activité du dashboard. Append-only, pas de mutation.

```ts
@column({ columnName: 'organization_id' }) declare organizationId: string
@column() declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@column.dateTime() declare at: DateTime
@column() declare label: string                  // texte HTML simple (<b> autorisé)
@column() declare meta: object                   // jsonb : { invoiceId?, clientId?, ... }
```

---

## 5. Routes API

**Toutes** sous `/api/v1/`. Le SPA consomme exactement cette surface (cf. `apps/web/src/mocks/handlers/`). Versioning explicite : V2 vivra côté `/api/v2/` sans casser V1.

### 5.1 Auth (public)

| Méthode | Route | Body | Réponse |
|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, fullName }` | `{ data: AuthSession }` (201) |
| POST | `/auth/login` | `{ email, password }` | `{ data: AuthSession }` |
| POST | `/auth/refresh` | — (cookie httpOnly) | `{ data: AuthSession }` ou 401 |

Réponse `AuthSession` :

```ts
{ accessToken: string, expiresAt: string /* ISO */, user: User }
```

### 5.2 Compte (auth)

| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/account/profile` | — | `{ data: User }` |
| PATCH | `/account/profile` | `{ fullName?, email?, signature? }` | `{ data: User }` |
| POST | `/account/logout` | — | 204 (clear le refresh cookie) |

### 5.3 Organisation (auth)

| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/organizations/me` | — | `{ data: Organization }` |
| PATCH | `/organizations/me` | `{ name?, siret?, monthlyVolumeBucket? }` | `{ data: Organization }` |

### 5.4 Clients (auth)

| Méthode | Route | Query | Body | Réponse |
|---|---|---|---|---|
| GET | `/clients` | `?withStats=1&q=` | — | `{ data: Client[] }` (avec stats si `withStats`) |
| GET | `/clients/:id` | — | — | `{ data: ClientDetail }` (avec invoices) |
| POST | `/clients` | — | `{ name, email, phone?, address?, siret?, notes? }` | `{ data: Client }` (201). 409 si doublon de nom. |
| PATCH | `/clients/:id` | — | `{ name?, email?, phone?, address?, siret?, notes? }` | `{ data: Client }` |

### 5.5 Plans (auth)

| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/plans` | — | `{ data: PlanWithUsage[] }` |
| GET | `/plans/:slug` | — | `{ data: PlanWithUsage }` |
| POST | `/plans` | `{ name, description, steps[] }` | `{ data: Plan }` (V2) |
| PATCH | `/plans/:slug` | `{ name?, description?, steps? }` | `{ data: Plan }` |

### 5.6 Factures (auth)

| Méthode | Route | Query | Body | Réponse |
|---|---|---|---|---|
| GET | `/invoices` | `?status=&q=&clientId=&page=` | — | `{ data: InvoiceListItem[], meta: { total, page } }` |
| GET | `/invoices/counts` | — | — | `{ data: StatusCounts }` |
| GET | `/invoices/:id` | — | — | `{ data: InvoiceDetail }` (client + plan + timeline) |
| POST | `/invoices` | — | `{ clientId?, clientName, clientEmail, numero, amountTtcCents, issueDate, dueDate, planId? }` | `{ data: Invoice }` (201) |
| POST | `/invoices/:id/mark-paid` | — | — | `{ data: Invoice }` |
| POST | `/invoices/:id/relance` | — | — | `{ data: Invoice }` (relance manuelle, V1 si scope) |

### 5.7 Import OCR (auth)

| Méthode | Route | Body | Réponse |
|---|---|---|---|
| POST | `/invoices/upload` | `multipart` (files) — V1 mock accepte `{ filenames: string[] }` | `{ data: ImportBatch }` (201) |
| GET | `/invoices/import-batch/:id` | — | `{ data: ImportBatch }` |
| POST | `/invoices/import-batch/:id/drafts/:draftId/validate` | DraftFields édités | `{ data: Invoice }` (201) |
| POST | `/invoices/import-batch/:id/drafts/:draftId/skip` | — | `{ data: ImportDraft }` |
| DELETE | `/invoices/import-batch/:id` | — | 204 (annule le batch entier) |

### 5.8 Dashboard (auth)

| Méthode | Route | Réponse |
|---|---|---|
| GET | `/dashboard/kpis` | `{ data: DashboardKpis }` |
| GET | `/dashboard/activity` | `{ data: ActivityEvent[] }` |
| GET | `/dashboard/top-late` | `{ data: LatePayer[] }` |

### 5.9 Check-in email (public, signed token)

| Méthode | Route | Réponse |
|---|---|---|
| GET | `/checkin/:token` | Redirect HTML vers le SPA avec confirmation |

> Pas d'auth Bearer ici — l'utilisateur clique depuis un email reçu. Sécurité = HMAC sur le token (TTL 24h après émission).

---

## 6. Conventions de réponse

**Succès** :

```json
{ "data": { ... }, "meta": { "page": 1, "total": 23 } }
```

**Erreur** :

```json
{ "errors": [{ "code": "validation_failed", "message": "...", "field": "email" }] }
```

**Codes HTTP** : 200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500.

**Pagination** : `?page=N` simple en V1, on bascule sur cursor-based si volume → cf. ADR à venir.

**Le `errors[].code`** est une chaîne stable que le SPA peut switcher dessus pour des messages contextuels :

- `validation_failed`
- `invalid_credentials`
- `email_taken`
- `client_email_required`
- `duplicate_client`
- `unauthenticated`
- `not_found`
- `forbidden`
- `rate_limited`

L'`ExceptionHandler` (`app/exceptions/handler.ts`) transforme les exceptions Vine, Lucid, Auth en cette enveloppe.

---

## 7. Auth — Bearer + refresh httpOnly cookie

Cf. ADR-017. Pattern hybride : access token stateless (Adonis access_tokens) + refresh token custom en cookie httpOnly.

### 7.1 Ce qui est natif Adonis

`@adonisjs/auth` avec guard `tokensGuard` couvre :

- `POST /auth/login` → crée un access token (TTL 30 min) → renvoyé en JSON
- `middleware.auth()` valide le `Authorization: Bearer <token>` sur les routes protégées
- `User.accessTokens.create(user)` / `User.accessTokens.delete(user, token)`

### 7.2 Ce qu'on ajoute custom — refresh token cookie

Adonis ne ship pas de refresh-token primitive. On en construit un sobrement :

**Schéma `refresh_tokens`** :

```ts
{ id, userId, hashedToken, expiresAt, lastUsedAt, revokedAt, ipAddress, userAgent }
```

**Service `RefreshTokenService`** :

```ts
async create(user: User, ctx: HttpContext): Promise<{ token: string; expiresAt: DateTime }>
async rotate(plainToken: string, ctx: HttpContext): Promise<{ user: User; newToken: string }>
async revoke(plainToken: string): Promise<void>
async revokeAllForUser(userId: string): Promise<void>
```

Le token plain (32 bytes random) est envoyé au SPA via cookie :

```ts
ctx.response.cookie('rubis_refresh', plainToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/api/v1/auth',
  maxAge: 30 * 24 * 60 * 60, // 30 jours
})
```

**Stocké hashé** côté DB (SHA-256 — pas besoin de bcrypt, c'est un opaque random pas un mot de passe). Sur `/auth/refresh`, on hash le cookie reçu, on cherche en DB, on vérifie expiry, on rotate (= invalide l'ancien, crée un nouveau, retourne nouveau access + cookie rotaté).

**Pourquoi rotation** : si quelqu'un vole le cookie, dès que le vrai user fait un refresh, le voleur perd l'accès (le rotated token devient invalide).

### 7.3 Endpoints

```ts
// start/routes/auth.ts
router.post('/auth/signup', '#controllers/auth/new_account_controller.store')
  .as('auth.signup')
router.post('/auth/login', '#controllers/auth/access_tokens_controller.store')
  .as('auth.login')
router.post('/auth/refresh', '#controllers/auth/refresh_controller.handle')
  .as('auth.refresh')
router.post('/account/logout', '#controllers/account/logout_controller.handle')
  .as('account.logout')
  // logout révoque le refresh + delete l'access token
```

### 7.4 Côté SPA

- Login / signup : reçoit `{ accessToken, expiresAt, user }` en JSON + cookie `rubis_refresh` posé automatiquement par le browser. SPA stocke `accessToken` en mémoire.
- Au boot, le SPA appelle `POST /auth/refresh` (le browser envoie le cookie). Si OK → réhydrate `authStore`. Si 401 → reste anonyme.
- Si une requête API retourne 401, le SPA tente un silent refresh puis retry.
- Logout : `POST /account/logout` → revoke + clear cookie. SPA clear son `authStore`.

> Le mock MSW actuel (`apps/web/src/mocks/sessionStore.ts`) simule ce flow via `localStorage` — quand le vrai backend arrive, `localStorage` disparaît, le cookie httpOnly prend le relais. **Pas de changement côté SPA** sauf retirer MSW.

---

## 8. Tuyau — client typé pour le SPA

Tuyau génère `apps/api/.adonisjs/api.ts` qui décrit toutes les routes, leurs paramètres, et leurs réponses, exposé via `apps/api/package.json` `exports`. Le SPA importe ces types et appelle `tuyau.api.v1.invoices.$get()` avec autocomplete + type-check.

### 8.1 Exposition côté API

`apps/api/package.json` (déjà fait par le scaffold) :

```json
{
  "exports": {
    "./data": "./.adonisjs/client/data.d.ts",
    "./registry": "./.adonisjs/client/registry/index.ts"
  }
}
```

### 8.2 Annoter les routes

Chaque route reçoit un nom (`as()`) — utilisé par Tuyau pour générer les types nommés.

```ts
router.get('/invoices', '#controllers/invoices_controller.index')
  .as('invoices.index')
  .use(middleware.auth())
```

### 8.3 Génération

```bash
cd apps/api
node ace tuyau:generate          # one-shot
pnpm tuyau:watch                 # dev — script à ajouter dans package.json
```

→ Régénère `.adonisjs/client/...` à chaque modif de routes/validators.

### 8.4 Côté SPA

```ts
// apps/web/src/lib/api.ts (à compléter quand on retire MSW)
import { createTuyau } from '@tuyau/client'
import { api } from '@rubis/api/registry'

export const tuyau = createTuyau({
  api,
  baseUrl: env.VITE_API_URL,
  credentials: 'include', // pour envoyer le cookie refresh
  headers: () => ({
    Authorization: authStore.token ? `Bearer ${authStore.token}` : '',
  }),
})
```

---

## 9. Validation — Vine

Adonis 7 utilise **Vine** comme validateur natif. On peut soit :

1. **Réécrire** les schemas en Vine (idiomatique Adonis, support natif des messages d'erreur i18n)
2. **Réutiliser** les schemas Zod de `packages/shared` côté serveur via `zod` + un wrapper qui transforme les `ZodError` en réponse 422 standardisée

Choix recommandé : **Vine côté API**, **Zod côté SPA** — on accepte la duplication parce qu'elle est minime (~5 schemas) et chaque côté reste idiomatique.

Exemple :

```ts
// apps/api/app/validators/client.ts
import vine from '@vinejs/vine'

export const createClientValidator = vine.compile(
  vine.object({
    name: vine.string().minLength(2).maxLength(120),
    email: vine.string().email().maxLength(120),               // requis !
    phone: vine.string().maxLength(40).nullable().optional(),
    address: vine.string().maxLength(500).nullable().optional(),
    siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
    notes: vine.string().maxLength(2000).nullable().optional(),
  }),
)
```

Le contrôleur appelle `await request.validateUsing(createClientValidator)` → throws `ValidationException` → l'exception handler transforme en `{ errors: [...] }` 422.

---

## 10. Storage — MinIO

`@adonisjs/drive` avec driver `s3` configuré pour MinIO (S3-compatible).

```ts
// config/drive.ts
const driveConfig = defineConfig({
  default: 's3',
  services: {
    s3: services.s3({
      credentials: {
        accessKeyId: env.get('S3_ACCESS_KEY'),
        secretAccessKey: env.get('S3_SECRET_KEY'),
      },
      endpoint: env.get('S3_ENDPOINT'),     // http://minio.minio.svc.cluster.local:9000
      region: env.get('S3_REGION'),
      bucket: env.get('S3_BUCKET'),         // rubis-prod-invoices en prod
      forcePathStyle: true,                  // requis pour MinIO
      visibility: 'private',
    }),
  },
})
```

### Conventions de keys

```
invoices/<orgId>/<invoiceId>/<filename>
import-drafts/<orgId>/<batchId>/<draftId>.<ext>
backups/pg/<date>.dump
```

### Upload depuis le contrôleur

```ts
const file = ctx.request.file('file', { extnames: ['pdf', 'png', 'jpg', 'jpeg'], size: '10mb' })
if (!file) throw new BadRequest('Fichier manquant')
const key = `import-drafts/${orgId}/${batchId}/${draft.id}.${file.extname}`
await drive.use().putStream(key, file.stream)
draft.pdfStorageKey = key
await draft.save()
```

### URLs signées

Pour permettre au SPA de prévisualiser un PDF sans exposer MinIO directement :

```ts
const url = await drive.use().getSignedUrl(invoice.pdfStorageKey, { expiresIn: '15 mins' })
```

→ exposé via `GET /invoices/:id` dans la réponse `pdfPreviewUrl`.

---

## 11. OCR pipeline

### 11.1 Interface abstraite

```ts
// app/services/ocr/ocr_provider.ts
export interface OcrProvider {
  extract(input: { storageKey: string; filename: string }): Promise<OcrResult>
}

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
    dueDate: { value: string; confidence: number }
  }
  rawProviderResponse?: unknown    // pour debug / re-process
}
```

### 11.2 Providers V1 candidats (cf. ADR-020)

| Provider | Pour | Contre |
|---|---|---|
| **Mindee** | Modèle "Invoice" pré-entraîné FR, API simple | Coût par page |
| **Google Document AI** | Très bonne qualité, bon FR | Setup GCP, coûteux |
| **AWS Textract** | Robuste, bien intégré S3 | Setup AWS, peu spécialisé facture FR |
| **Tesseract local** | Gratuit, on-prem | Qualité OCR brute, pas d'extraction structurée — il faudrait écrire le parsing à la main |

Recommandation pour V1 : **Mindee** comme default, abstraction switchable (cf. interface). Tesseract en fallback dev.

### 11.3 Job différé

```ts
// app/jobs/process_ocr_job.ts
export default class ProcessOcrJob extends Job {
  static get $$filepath() { return import.meta.url }

  async handle({ batchId, draftId }: { batchId: string; draftId: string }) {
    const draft = await ImportDraft.query()
      .where('id', draftId)
      .firstOrFail()

    const ocr = container.make(OcrProvider)
    const result = await ocr.extract({
      storageKey: draft.pdfStorageKey,
      filename: draft.filename,
    })

    draft.extracted = result.fields
    draft.edited = { ...result.fields }   // édition initiale = extraction
    draft.confidence = mapConfidence(result.fields)
    draft.status = 'pending'              // prêt à review
    await draft.save()
  }
}
```

**Idempotence** : `processOcrJob` doit pouvoir être rejoué (cf. retry BullMQ). Si le draft est déjà `validated` ou `skipped`, no-op.

---

## 12. Email outbound

### 12.1 Interface

`@adonisjs/mail` avec Mailers (= providers) :

```ts
// config/mail.ts
const mailConfig = defineConfig({
  default: 'resend',
  from: { address: env.get('MAIL_FROM_ADDRESS'), name: 'Rubis' },
  mailers: {
    resend: services.resend({ key: env.get('RESEND_API_KEY') }),
  },
})
```

### 12.2 Templates

Chaque ton a son template (`app/mails/relance_amical_mail.ts`, etc.) — Edge syntax pour le HTML.

Variables fournies :

```ts
{
  client: { name, email },
  invoice: { numero, amountTtc, dueDate, issueDate },
  signature: user.signature,
  unsubscribeUrl: ...,                    // RGPD
  trackingPixelUrl: ...,                  // V2 — ouverture
}
```

Les templates utilisent les vars définies dans les step.subject / step.body du Plan, avec interpolation Mustache-like (cf. variables ` {{client.name}} ` etc.).

### 12.3 Provider switchable (cf. ADR-021)

| Provider | Pour | Contre |
|---|---|---|
| **Resend** | Simple, prix correct, bonne deliverability FR | Provider US |
| **Postmark** | Excellence transactionnelle | Cher au volume |
| **AWS SES** | Très peu cher au volume | Setup, less DX |
| **OVH** / **Mailjet** | EU souverain | DX moins bonne |

Recommandation V1 : **Resend** comme default, abstraction `@adonisjs/mail` permet de switch sans toucher la logique métier.

### 12.4 Anti-abuse

- Rate limit côté API (5 emails/heure par invoice)
- Vérification de l'adresse expéditrice (DNS configuré, SPF + DKIM signés)
- Mention "Envoyé via Rubis" obligatoire en footer (transparence pour le destinataire)

### 12.5 Infrastructure email du domaine `rubis.pro`

Deux flux distincts, deux providers, **pas de conflit** parce qu'ils utilisent des
sous-domaines différents au niveau DNS.

#### Sortant (transactionnel) — Resend

Pour les emails **envoyés depuis l'app** (relances clients, check-in utilisateur,
auth/password-reset, notifications billing).

| Aspect | Valeur |
|---|---|
| Provider | [Resend](https://resend.com) (cf. ADR-021) |
| Driver AdonisJS | `transports.resend()` (`config/mail.ts`) |
| Adresse expéditrice | `relances@rubis.pro` (configurable via `MAIL_FROM_ADDRESS`) |
| Auth DNS | DKIM (TXT `resend._domainkey`) + SPF (TXT `send`) + return-path MX (`send`) |
| DMARC | TXT `_dmarc` → `v=DMARC1; p=none;` (mode monitoring) |
| Click tracking | CNAME `mail.rubis.pro` → `links1.resend-dns.com.` (optionnel) |

**Délivrabilité** : tous les records DNS doivent être en statut "Verified" dans le dashboard Resend
avant de switcher `MAIL_FROM_ADDRESS` sur `relances@rubis.pro` côté secret K3s.

#### Entrant (humain) — OVH MX Plan

Pour les emails **reçus par l'équipe** (questions support, demandes RGPD,
factures fournisseurs, etc.). **Pas de webhook**, vraies boîtes mail OVH avec
IMAP/SMTP/webmail standard.

| Adresse | Usage |
|---|---|
| `contact@rubis.pro` | Contact général + demandes RGPD (ref. mentions légales / confidentialité) |
| `dev@rubis.pro` | Notifs techniques, abonnements aux outils dev (Gitea, monitoring, etc.) |

| Aspect | Valeur |
|---|---|
| Provider | OVH MX Plan (gratuit, inclus avec le domaine acheté chez OVH) |
| Capacité | 5 boîtes 5 Go par domaine |
| MX records | Auto-configurés par OVH (`mx*.mail.ovh.net.`) lors de l'activation MX Plan |
| Webmail | https://www.ovh.com/mail/ |
| Apps mobiles / desktop | IMAP `ssl0.ovh.net:993`, SMTP `ssl0.ovh.net:465` |

**Important — coexistence avec Resend** :
- Resend utilise le sous-domaine **`send.rubis.pro`** pour son MX return-path.
- OVH MX Plan utilise l'apex **`@`** (`rubis.pro`) pour la réception.
- Les deux MX cohabitent sans conflit dans la zone DNS, chacun sur son hostname.

⚠️ Si on active un jour Resend Inbound (V2 — capter les replies clients pour
auto-bumper le statut invoice), il **faudra choisir** entre OVH MX Plan et Resend
Inbound : les deux veulent le MX `@`. Plan probable : on garde OVH pour
`contact@`/`dev@`/etc. (humains) et on crée un sous-domaine dédié style
`replies.rubis.pro` pour Resend Inbound (sender Reply-To = `<token>@replies.rubis.pro`).

---

## 13. Background jobs

### 13.1 Stack

BullMQ direct (sans wrapper Adonis) (Adonis 7 wrapper de BullMQ) + Redis comme backend.

```ts
// config/queue.ts
const queueConfig = defineConfig({
  connection: {
    host: env.get('REDIS_HOST'),
    port: env.get('REDIS_PORT'),
  },
  queues: {
    ocr: { name: 'ocr', concurrency: 2 },
    relances: { name: 'relances', concurrency: 5 },
    checkins: { name: 'checkins', concurrency: 5 },
  },
})
```

### 13.2 Jobs

Réalité V1 (cf. `apps/api/app/jobs/`) :

| Job (fichier) | Trigger | Idempotent | Retry |
|---|---|---|---|
| `send_relance_job.ts` | RelanceTask.sendAt | oui (status=sent → no-op) | 5× |
| `send_checkin_job.ts` | CheckinTask.sendAt | oui | 3× |
| `send_payment_thanks_job.ts` | confirmation paiement (step 04 du flow) | oui | 3× |

**OCR** : pas de job dédié — l'extraction est synchrone via `app/services/import_batch.ts` (appel Mistral bloquant pendant l'upload). Si le volume monte, basculer en job différé.

**KPIs** : calculés on-the-fly à chaque GET dashboard/timeseries (cf. commentaire dans `start/routes.ts`). Pas de `RecomputeKpisJob` — pas de cache Redis pour les KPIs en V1.

### 13.3 Programmation des relances

Quand l'utilisateur valide une facture (`POST /invoices/:id/validate-import` ou création manuelle) :

1. On lit `invoice.planId` → `plan.steps`
2. Pour chaque step, on calcule `sendAt = invoice.dueDate + step.offsetDays`
3. On crée une `RelanceTask` (status=scheduled) + on enqueue un `SendRelanceJob` avec `delay: sendAt - now`
4. On crée éventuellement un `CheckinTask` à `sendAt - 2j` (cf. flow check-in)

À l'exécution du job :

```ts
async handle({ relanceTaskId }) {
  const task = await RelanceTask.findOrFail(relanceTaskId)
  if (task.status !== 'scheduled') return // idempotent

  const invoice = await Invoice.query()
    .where('id', task.invoiceId)
    .preload('client')
    .preload('plan', q => q.preload('steps'))
    .firstOrFail()

  // Hook critique : la facture peut être devenue paid entre-temps
  if (invoice.status === 'paid' || invoice.status === 'cancelled') {
    task.status = 'cancelled'
    await task.save()
    return
  }

  const step = invoice.plan.steps.find(s => s.id === task.planStepId)
  if (step.requiresManualValidation) {
    // Mise en demeure : on génère un brouillon, on n'envoie pas
    await activityRecorder.draft(invoice, step)
    return
  }

  await mailDispatcher.sendRelance({ invoice, step })
  task.status = 'sent'
  task.sentAt = DateTime.now()
  await task.save()

  await activityRecorder.relanceSent(invoice, step)
  await rubisCalculator.creditOne(invoice.organizationId)  // +1 rubis
}
```

### 13.4 Worker Pod

K3s pod séparé du pod web `rubis-api` :

```yaml
- Deployment: rubis-worker
    command: ["node", "ace", "queue:listen", "--queue=ocr,relances,checkins"]
    replicas: 1                    # V1, scale up plus tard
```

---

## 14. Tests — Japa

Tests d'intégration HTTP via `@japa/api-client`. Pattern :

```ts
// tests/functional/clients.spec.ts
import { test } from '@japa/runner'
import { OrganizationFactory, UserFactory, ClientFactory } from '#database/factories'

test.group('Clients API', (group) => {
  group.each.setup(() => testUtils.db().withGlobalTransaction())

  test('POST /clients refuse sans email', async ({ client }) => {
    const user = await UserFactory.create()
    const response = await client
      .post('/api/v1/clients')
      .json({ name: 'Boulangerie Test' })
      .loginAs(user)

    response.assertStatus(422)
    response.assertBodyContains({
      errors: [{ code: 'validation_failed', field: 'email' }],
    })
  })

  test('POST /clients crée un client avec email', async ({ client }) => {
    const user = await UserFactory.create()
    const response = await client
      .post('/api/v1/clients')
      .json({
        name: 'Boulangerie Test',
        email: 'compta@boulangerie-test.fr',
      })
      .loginAs(user)

    response.assertStatus(201)
    response.assertBodyContains({ data: { name: 'Boulangerie Test' } })
  })
})
```

**Couverture cible V1** :

- Auth flows complets (signup, login, refresh, logout, expired token)
- Permissions cross-org (un user A ne peut pas voir/modifier les ressources du user B)
- Validation Vine (toutes les contraintes critiques : email required client, SIRET format, etc.)
- Idempotence des jobs OCR / Relance
- Edge cases métier : `mark-paid` annule les RelanceTasks futures, plan édité ne casse pas les invoices en cours

---

## 15. Migrations + seeders

### 15.1 Migrations

Adonis Lucid, fichiers `database/migrations/<timestamp>_<name>.ts`. Convention : **jamais éditer une migration commitée**, on en crée une nouvelle.

Ordre logique pour V1 :

```
1. organizations
2. users (FK organization_id)
3. refresh_tokens (FK user_id)
4. clients (FK organization_id)
5. plans + plan_steps
6. invoices (FK client_id, plan_id)
7. relance_tasks (FK invoice_id, plan_step_id)
8. checkin_tasks (FK invoice_id)
9. import_batches + import_drafts
10. activity_events
```

### 15.2 Seeders

`database/seeders/default_plans_seeder.ts` recrée les 4 plans pré-fournis (Standard B2B, Rapide, Patient, Ferme) — **identiques** à ceux dans `apps/web/src/mocks/seed.ts`. Source de vérité : on extrait les plans dans `packages/shared/seeds/` (à créer) pour qu'API et MSW partagent les mêmes données.

`database/seeders/demo_data_seeder.ts` (dev only) crée le compte `demo@rubis.fr` + ses clients + factures. Match `mocks/seed.ts`.

```bash
node ace migration:run
node ace db:seed
```

---

## 16. Variables d'environnement

`apps/api/.env` (déjà scaffold partiellement par Adonis) :

```bash
# App
NODE_ENV=production
TZ=UTC
PORT=3333
HOST=0.0.0.0
LOG_LEVEL=info
APP_KEY=<32-chars-random>          # cookies + crypto
SESSION_DRIVER=cookie

# CORS
CORS_ORIGINS=https://app.rubis.pro

# DB
DB_CONNECTION=postgres
PG_HOST=lxc-postgres.proxmox.local
PG_PORT=5432
PG_USER=rubis
PG_PASSWORD=<secret>
PG_DB_NAME=rubis_prod

# Redis (queue + cache)
REDIS_HOST=lxc-redis.proxmox.local
REDIS_PORT=6379

# MinIO
S3_ENDPOINT=http://minio.minio.svc.cluster.local:9000
S3_ACCESS_KEY=<secret>
S3_SECRET_KEY=<secret>
S3_REGION=us-east-1
S3_BUCKET=rubis-prod-invoices       # bucket réel en prod (cf. k3s/app/api.yml)

# Mail (Resend)
MAIL_FROM_ADDRESS=relances@rubis.pro
MAIL_FROM_NAME=Rubis Sur l'Ongle
RESEND_API_KEY=<secret>

# OCR (Mindee — cf. ADR-020 pending)
OCR_PROVIDER=mindee
MINDEE_API_KEY=<secret>

# Auth refresh tokens
REFRESH_TOKEN_TTL_DAYS=30
ACCESS_TOKEN_TTL_MINUTES=30
COOKIE_DOMAIN=.rubis.pro
COOKIE_SECURE=true
```

Validation au boot via `start/env.ts` (Adonis 7 valide chaque var avec un schema Vine).

---

## 17. Build & déploiement

### 17.1 Build local

```bash
pnpm -F api build
# → produit apps/api/build/ (JS compilé + node_modules pruné)
```

### 17.2 Image Docker

`Dockerfile.api` à la racine :

```dockerfile
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api ./apps/api
COPY packages/shared ./packages/shared
RUN pnpm install --frozen-lockfile
RUN pnpm -F api build

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/apps/api/build ./build
COPY --from=builder /app/apps/api/package.json ./
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile
EXPOSE 3333
CMD ["node", "build/bin/server.js"]
```

### 17.3 Pipeline CI Gitea

```
.gitea/workflows/build-api.yml
  → build & push image git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
  → kubectl rollout sur namespace rubis
```

### 17.4 K3s

```yaml
- Deployment: rubis-api (port 3333, replicas: 2)
- Deployment: rubis-worker (queue listener, replicas: 1)
- Service: rubis-api-svc
- IngressRoute Traefik: api.rubis.pro → rubis-api-svc:3333
- Secret: rubis-config (toutes les vars d'env)
```

### 17.5 Migrations à la release

CronJob K3s qui run `node ace migration:run --force` avant chaque rollout. Alternative : initContainer dans le deployment qui bloque le démarrage si une migration pending.

---

## 17-bis. Observability — Sentry (ADR-024)

Error monitoring API via `@sentry/node`. Init au plus tôt dans
`bin/server.ts` AVANT le boot Ignitor pour capturer même les erreurs
de bootstrap.

### Configuration

```ts
// apps/api/start/sentry.ts
// No-op si SENTRY_DSN_API absent → dev local sans bruit Sentry par défaut
```

| Aspect | Valeur | Rationale |
|---|---|---|
| Sample rate traces | 10 % prod, 100 % dev | Quota free tier (5K events/mois) |
| Sample rate profiles | 100 % | Sampled par traces de toute façon |
| `release` | `APP_VERSION` runtime (sha git) | Set par `kubectl set env` post-deploy |
| User context | `user.id` UUID seulement | Pas d'email/nom (PII minimisée) |
| `beforeSend` | drop 4xx | Validation, auth invalide = bruit |

### Capture automatique

Le `report()` du `HttpExceptionHandler` (`apps/api/app/exceptions/handler.ts`)
capture **uniquement les 5xx** (status >= 500 ou status absent). Tout le
reste — `E_INVALID_CREDENTIALS`, `E_VALIDATION_ERROR`, custom 4xx —
n'arrive **jamais** dans Sentry.

```ts
// handler.ts:report (extrait)
const isServerError = !status || status >= 500
if (isServerError) {
  Sentry.captureException(error, {
    tags: {
      // PATTERN de route (/api/v1/checkin/:token/paid), pas l'URL réelle
      // → les codes OAuth, tokens checkin, etc. ne fuitent JAMAIS dans
      //   les tags Sentry indexés.
      url: ctx.route?.pattern ?? ctx.request.url(false),
      method: ctx.request.method(),
      status: status?.toString() ?? '500',
    },
    user: ctx.auth?.user
      ? { id: String(ctx.auth.user.id) }
      : undefined,
  })
}
```

### Capturer manuellement (cas métier)

Dans un service ou job où une exception métier doit être tracée :

```ts
import * as Sentry from '@sentry/node'

try {
  await mistralOcr.extract(buffer)
} catch (err) {
  Sentry.captureException(err, {
    tags: { feature: 'ocr', provider: 'mistral' },
    extra: { batchId, fileSize: buffer.length },
  })
  throw err  // re-throw pour que le contrôleur fasse son job de réponse
}
```

### Tester l'intégration en prod (E2E)

Endpoint debug `/api/v1/_debug/sentry-test` (gardé par
`NODE_ENV !== 'production'` OU `DEBUG_SENTRY_TEST=true`). Pour activer
temporairement sur la prod et lancer un test :

```bash
KUBECTL="kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml -n rubis"

$KUBECTL set env deploy/rubis-api DEBUG_SENTRY_TEST=true
$KUBECTL rollout status deploy/rubis-api --timeout=120s

curl -i https://app.rubis.pro/api/v1/_debug/sentry-test
# 500 attendu — vérifier Sentry sous 30s

$KUBECTL set env deploy/rubis-api DEBUG_SENTRY_TEST-
```

### Variables d'env requises

- **K3s secret `rubis-app-secrets`** : `SENTRY_DSN_API` (DSN privé du projet rubis-api)
- **K3s ConfigMap (set par CI)** : `APP_VERSION` = sha git pour le tag release

---

## 18. Pointeurs vers l'existant

Avant de coder un endpoint, **toujours consulter** :

- **Le contrat MSW** : `apps/web/src/mocks/handlers/<domain>.ts` — le SPA y est déjà branché, c'est exactement ce qu'on doit servir
- **Les types/schemas partagés** : `packages/shared/src/types/` et `packages/shared/src/schemas/` — réutiliser les types TS dans Lucid models, traduire les schemas Zod en validators Vine équivalents
- **Le seed MSW** : `apps/web/src/mocks/seed.ts` — base pour les seeders Adonis (plans pré-fournis identiques)
- **L'auth scaffold** : `apps/api/app/controllers/access_tokens_controller.ts` etc. — le squelette est là, on étend
- **L'architecture macro** : `docs/tech/architecture.md`

---

## 19. Décisions encore à prendre côté backend

| Sujet | Quand trancher | Notes |
|---|---|---|
| **ADR-019 — Domain model** détaillé (index, contraintes, soft-delete) | Avant la 1ère migration | Privilégier les FKs cascade + soft-delete sur Invoice/Client (audit) |
| **ADR-020 — Provider OCR** | Avant l'implémentation de `ProcessOcrJob` | Mindee semble en tête. Tester sur 50 factures réelles. |
| **ADR-021 — Provider email** | Avant la 1ère relance live | Resend default. Vérifier deliverability vers boîtes pro FR. |
| **ADR-022 — Pricing exact** | Avant le payment flow | Stripe ou GoCardless ? V1 = waitlist + manuel |
| **ADR-023 — Refresh token rotation policy** | Avant prod | Quand rotater (chaque refresh ? toutes les 24h ?) — sécurité vs UX |
| **ADR-024 — Backup PG strategy** | Avant les premières données prod | Dump quotidien + retention 30j minimum |
| **ADR-025 — Webhook in/out shape** | V2 — banking + comptable | Modèle d'événements abstrait `invoice.created`, `invoice.paid` etc. |

---

## 20. Évolutions V2+ anticipées

- **Multi-utilisateurs** : `memberships` table (user × organization × role). Le filtre `organization_id` actuel facilite la transition.
- **SMS** : provider (Twilio/OVH) abstrait derrière `MessageDispatcher` qui route email/sms selon plan + cadence.
- **Banking integration** : webhook entrant `/api/v1/banking/payment-confirmed` → mark invoice paid auto. Le check-in email V1 devient fallback.
- **Intégrations comptables** (Pennylane, Sage) : événements webhook sortants `invoice.*` exportables.
- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée).
- **Recherche full-text** : PG `tsvector` sur `invoices.notes`, `clients.notes`, `clients.name` — indexe une fois les volumes pertinents.

---

*Maintenu par Arthur + Claude. Les décisions structurelles passent par un ADR dans `/docs/decisions.md`.*
