rubis/docs/tech/architecture.md
ordinarthur e449b708f3 docs(invoices): édition native + ADR-025 + roadmap Factur-X (Phase 5)
Documente la feature ajoutée en V1.1 dans toute la doc cadre :

- **CLAUDE.md** : "Pure-player relance" nuancé en "La relance reste
  l'âme du produit", extension douce assumée. Périmètre V1/IN
  enrichi avec l'éditeur de factures. Glossaire enrichi (facture
  native, numéro de séquence, snapshot, Factur-X). Stack : ajout
  @react-pdf/renderer + pointeurs vers pdf-templates et les routes
  /parametres/facturation et /factures/nouvelle.
- **docs/produit.md** : nouvelle section 4.2bis "Édition native des
  factures" — scope V1.1 minimal, snapshots immuables, numérotation
  strict séquentielle, roadmap Factur-X V1.5 / PDP V2.
- **docs/flow.md** : nouvelle section 11bis (3 sources d'une facture,
  flow utilisateur de création, génération PDF, numérotation,
  snapshots, cas limites). Tableau "Ce que Rubis ne fait PAS" mis à
  jour (édition oui mais pas devis/avoirs/Factur-X V1).
- **docs/decisions.md** : ADR-025 "Édition native des factures +
  roadmap Factur-X" (rationale extension douce, choix techniques
  notables, alternatives écartées).
- **docs/tech/architecture.md** : section 6.1bis (flow technique
  édition native, points d'attention numérotation atomique + lazy
  PDF regenerate), ajout @react-pdf à la stack, routes /native +
  /preview-pdf + /invoice-themes + /invoice-settings documentées.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:18:11 +02:00

31 KiB
Raw Blame History

Architecture technique — Rubis Sur l'Ongle

Version : 0.1 · Dernière maj : 2026-05-05 Décisions de référence : ADR-014 (stack), ADR-015 (repo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage). Voir /docs/decisions.md.

Ce document est la source de vérité technique. Quand le code et ce fichier divergent, on tranche en discussion et on met à jour ici.


1. Vue d'ensemble

                     ┌────────────────────────────┐
                     │      Internet (HTTPS)       │
                     └─────────────┬──────────────┘
                                   │
                     ┌─────────────▼──────────────┐
                     │  Traefik (Proxmox gateway)  │
                     │  Host(rubis.pro)            │
                     │  Host(app.rubis.pro)        │
                     └─────────────┬──────────────┘
                                   │
        ┌──────────────────────────┼─────────────────────────┐
        │                          │                         │
┌───────▼─────────┐       ┌────────▼─────────┐    ┌──────────▼────────┐
│ Pod: rubis-     │       │ Pod: rubis-web   │    │  Pod: rubis-api   │
│ landing (Astro) │       │ (nginx + Vite)   │    │  (AdonisJS Node)  │
│ rubis.pro/*     │       │ app.rubis.pro/*  │    │  /api/v1/* +      │
│ (SSG + SSR)     │       │ (proxy /api → api)│    │  jobs BullMQ      │
└───────┬─────────┘       └────────┬─────────┘    └──────────┬────────┘
        │ SSR fetch JSON            │                         │
        └─────────────►──────────────────────►────────────────┘
                                    │
            ┌───────────────────────┼───────────────────────┐
            │                       │                       │
   ┌────────▼─────────┐    ┌────────▼────────┐    ┌─────────▼────────┐
   │ LXC: PostgreSQL  │    │  LXC: MinIO     │    │  Provider OCR    │
   │ (existant)       │    │  (S3-compat)    │    │  (à benchmarker) │
   └──────────────────┘    └─────────────────┘    └──────────────────┘

Composants :

Composant Rôle Hosting Status
apps/landing Landing + blog publics (Astro 6 SSR) Pod Node K3s (rubis-landing:4321) Déployé
apps/web SPA React + TanStack Router/Query (SaaS) nginx pod K3s (build statique + proxy /api/* → rubis-api) Déployé
apps/api API REST AdonisJS v7 — logique métier, jobs BullMQ inline, email, billing Stripe Pod Node K3s (rubis-api:3333) Déployé
packages/shared Types TS, schemas Zod, constantes (statuts, tons, plans, etc.) workspace local
packages/ui Design system partagé (tokens Tailwind v4 + composants TSX) workspace local
PostgreSQL Base de données métier LXC Proxmox existant (10.10.10.3) En place
MinIO Stockage PDF + uploads blog (S3-compat, bucket rubis-prod-invoices) namespace K3s minio En place
Redis Backend BullMQ (queues: relances, checkins, payment-thanks) Deployment K3s rubis-redis + PVC Déployé
Provider OCR Extraction texte des factures Mistral (OCR_PROVIDER=mistral) Choisi
Provider Email outbound Resend (relances@rubis.pro, sub-domaine send.rubis.pro) Externe (HTTPS) En place
Provider Email inbound OVH MX (contact@rubis.pro, dev@rubis.pro) Externe (HTTPS/IMAP) En place

2. Repo layout (monorepo)

rubis/
├── apps/
│   ├── api/                    # AdonisJS v7 backend
│   │   ├── app/                # Controllers, models, services
│   │   ├── config/             # Auth, database, mail, queue
│   │   ├── database/
│   │   │   ├── migrations/
│   │   │   └── seeders/
│   │   ├── start/              # Routes, kernel
│   │   ├── tests/
│   │   ├── ace.js              # CLI Adonis
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── web/                    # SPA React Vite (app.rubis.pro)
│   │   ├── src/
│   │   │   ├── routes/         # TanStack Router (file-based)
│   │   │   ├── components/     # composants app-spécifiques
│   │   │   ├── lib/            # api client, query keys, utils
│   │   │   └── main.tsx
│   │   ├── public/
│   │   ├── vite.config.ts
│   │   └── package.json
│   └── landing/                # Astro SSR (rubis.pro + /blog)
│       ├── src/
│       │   ├── pages/          # routes file-based (.astro + .ts endpoints)
│       │   ├── layouts/        # Layout.astro, LegalLayout.astro
│       │   ├── components/     # SiteHeader/Footer + sections + blog
│       │   ├── lib/api.ts      # client API typé
│       │   └── styles/app.css
│       ├── public/             # favicons, manifest
│       ├── astro.config.mjs
│       └── package.json
├── packages/
│   ├── shared/                 # Types TS + schemas Zod (api ↔ web)
│   │   └── src/{types,schemas,constants}
│   └── ui/                     # Design system partagé (apps/web + apps/landing)
│       └── src/{components,lib,styles}
├── docs/
│   └── tech/architecture.md    # (ce fichier)
├── k3s/
│   ├── namespace.yml
│   └── app/{api,web,landing,redis}.yml
├── Dockerfile.api              # AdonisJS Node SSR
├── Dockerfile.web              # nginx + bundle Vite
├── Dockerfile.landing          # Astro Node SSR (standalone)
├── pnpm-workspace.yaml
└── CLAUDE.md

Outils monorepo :

  • pnpm workspaces — léger, rapide, gestion native des liens symboliques entre packages
  • TypeScript project references — résout les imports cross-package sans build préalable
  • Turborepo (optionnel, à voir au volume) — cache + parallélisation des scripts

Commandes racine typiques :

pnpm install                  # installe tout
pnpm -F api dev              # dev API
pnpm -F web dev              # dev SPA
pnpm -F api migration:run    # migrations DB
pnpm -F api test             # tests API
pnpm build                   # build api + web pour prod

3. apps/api — AdonisJS v7

Stack interne

  • AdonisJS v7 + Lucid ORM (PG)
  • @adonisjs/auth — access tokens Bearer (stateless)
  • @adonisjs/bouncer — autorisations par policy (admin/lecture/édition pour V2 multi-users)
  • @adonisjs/mail — emails outbound (provider à choisir ADR-021)
  • @adonisjs/queue ou BullMQ — jobs différés (relances programmées, OCR, check-ins)
  • @adonisjs/limiter — rate limiting sur les routes publiques (login, signup)
  • Vine (validateur natif Adonis 7) ou Zod côté API pour validation des payloads
  • @react-pdf/renderer (ADR-025) — génération des PDF de factures natives côté serveur. 4 templates TSX dans apps/api/app/pdf-templates/ (classique.tsx, moderne.tsx, minimal.tsx, elegant.tsx) + common.tsx (formatters fr-FR, palette) + index.tsx (dispatcher slug → composant). L'import alias #pdf-templates/* est déclaré dans package.json.

Conventions de routes

Toutes les routes API sous /api/v1/. Versioning explicite — V2 vivra côté /api/v2/ sans casser V1.

POST   /api/v1/auth/register
POST   /api/v1/auth/login
POST   /api/v1/auth/logout
POST   /api/v1/auth/refresh

GET    /api/v1/me
PATCH  /api/v1/me
GET    /api/v1/organizations/:id

GET    /api/v1/invoices
POST   /api/v1/invoices                 # create manual
POST   /api/v1/invoices/native          # editor natif (ADR-025)
POST   /api/v1/invoices/preview-pdf     # preview PDF stream (debounced)
POST   /api/v1/invoices/upload          # OCR pipeline
GET    /api/v1/invoices/:id
PATCH  /api/v1/invoices/:id
DELETE /api/v1/invoices/:id
GET    /api/v1/invoices/:id/pdf         # PDF natif (lazy regenerate si manquant)
POST   /api/v1/invoices/:id/relance     # relance manuelle
POST   /api/v1/invoices/:id/mark-paid

GET    /api/v1/invoice-themes           # liste des 4 thèmes (ADR-025)

GET    /api/v1/plans
POST   /api/v1/plans
PATCH  /api/v1/plans/:id
DELETE /api/v1/plans/:id

GET    /api/v1/clients
POST   /api/v1/clients
PATCH  /api/v1/clients/:id

GET    /api/v1/organizations/me/invoice-settings   # ADR-025
PATCH  /api/v1/organizations/me/invoice-settings

GET    /api/v1/dashboard/kpis
GET    /api/v1/dashboard/activity

Conventions de réponse

  • JSON systématique
  • Format succès : { data: ..., meta?: { ... } }
  • Format erreur : { errors: [{ code, message, field? }] }
  • Codes HTTP standards (200, 201, 204, 400, 401, 403, 404, 422, 500)
  • Pagination cursor-based pour les listes (préférable à offset pour les flux modifiés en temps réel)

Endpoints blog publics

L'API expose les articles du blog en JSON (consommés par apps/landing en SSR). L'API ne sert plus de HTML — le rendu HTML déménagé côté Astro.

Route Description
GET /api/v1/posts Liste des articles publiés (summary, sans contentHtml)
GET /api/v1/posts/:slug Article complet (contentHtml inclus) + 3 articles liés

Cf. apps/api/app/controllers/blog_controller.ts + apps/api/app/transformers/post_transformer.ts.

Pipeline de contenu :

  1. Manuel V1 : node ace seed:blog insère/upserte les 3 articles fondateurs.
  2. Admin (PR3) : page React /admin/blog sur app.rubis.pro (auth-gated) — édition markdown + preview + bouton publier. Le markdown est rendu en HTML via app/services/blog_renderer.ts (marked v15, GFM, IDs auto) au save, cache dans posts.content_html.
  3. Cron IA hebdo (PR4) : apps/api/app/jobs/weekly_blog_post.ts génère un draft Sonnet 4.6 chaque lundi 06:00 Europe/Paris ; review humaine obligatoire avant publish.

4. apps/web — React + Vite

Stack interne

  • React 19 + Vite 6
  • TanStack Router — routing file-based (à privilégier), search params type-safe pour les filtres facture
  • TanStack Query — cache + invalidation + optimistic updates pour le state serveur
  • TailwindCSS (à confirmer) — utility-first, cohérent avec les couleurs de marque
  • Lucide React pour les icônes
  • Bricolage Grotesque + Inter via Google Fonts (cohérent landing)

Versionnage SPA + toast de release

  • apps/web/src/version.ts exporte APP_VERSION (semver string) et CHANGELOG_URL (https://rubis.pro/changelog).
  • Bump manuel à chaque release, dans la même PR que l'ajout de l'entrée changelog côté apps/landing.
  • <VersionToast/> (mount-once dans __root.tsx) compare cette constante à localStorage["rubis:last-seen-version"]. Toast persistant Sonner si différent. Cf. apps/web/src/components/version-toast.tsx.
  • localStorage indisponible (private mode) → fail silent, pas de toast et pas de crash.

Auth côté SPA (cf. ADR-017)

  • Access token stocké en mémoire (variable de module / state Query) — pas localStorage pour éviter XSS
  • Refresh token en cookie httpOnly + SameSite=Strict
  • Au boot du SPA : appel /auth/refresh pour obtenir un nouvel access token (silent reauth)
  • Si refresh échoue → redirect /login

Organisation des routes

File-based via TanStack Router :

src/routes/
├── __root.tsx            # Layout global + AuthGate
├── login.tsx
├── signup.tsx
├── _app/                  # Routes protégées
│   ├── _app.tsx           # Layout app (sidebar, header, brand)
│   ├── index.tsx          # Dashboard
│   ├── factures.tsx       # Liste factures
│   ├── factures.$id.tsx   # Détail facture
│   ├── plans.tsx          # Bibliothèque plans
│   ├── plans.$id.tsx      # Éditeur plan
│   ├── clients.tsx
│   └── parametres.tsx
└── onboarding/
    ├── compte.tsx
    ├── entreprise.tsx
    └── signature.tsx

4bis. apps/landing — Astro 6 (rubis.pro)

Landing publique + blog, en Astro avec rendu hybride statique/SSR. Image Docker rubis-landing (Node 22 standalone), service ClusterIP rubis-landing:4321.

Stack interne

  • Astro 6 + adapter @astrojs/node (mode standalone)
  • React 19 via @astrojs/react pour les composants interactifs / sections complexes
  • Tailwind v4 via @tailwindcss/vite
  • @rubis/ui — tokens + composants partagés avec apps/web

Stratégie de rendu

output: "server" dans astro.config.mjs → SSR par défaut, override par page :

Page Mode Raison
/ (landing) SSG (prerender = true) HTML figé, LCP/CLS optimaux, copy change rarement
/mentions-legales, /confidentialite, /cgv SSG Pages légales, mises à jour planifiées
/blog SSR Doit refléter immédiatement les publications admin
/blog/:slug SSR Idem + 404 dynamique
/blog/rss.xml, /sitemap.xml, /robots.txt SSR (endpoints .ts) Liste à jour
/changelog, /changelog/rss.xml SSG (prerender = true) Contenu MD versionné, regénéré à chaque release

Cache HTTP : pages SSR avec Cache-Control: public, max-age=300, stale-while-revalidate=86400. Absorbe les pics, et un publish admin propage en ≤5 min sans purge active.

Structure

apps/landing/
├── astro.config.mjs        # output: server, adapter Node, tailwindcss plugin
├── src/
│   ├── content.config.ts            # collections Astro (changelog)
│   ├── content/changelog/           # 1 .md par version livrée (frontmatter Zod)
│   │   ├── 1.0.0.md ... 1.10.0.md
│   ├── pages/
│   │   ├── index.astro              # / (SSG)
│   │   ├── mentions-legales.astro   # SSG
│   │   ├── confidentialite.astro    # SSG
│   │   ├── cgv.astro                # SSG
│   │   ├── sitemap.xml.ts           # SSR endpoint
│   │   ├── robots.txt.ts            # SSR endpoint
│   │   ├── blog/
│   │   │   ├── index.astro          # /blog (SSR)
│   │   │   ├── [slug].astro         # /blog/:slug (SSR)
│   │   │   └── rss.xml.ts           # SSR endpoint
│   │   └── changelog/
│   │       ├── index.astro          # /changelog (SSG)
│   │       └── rss.xml.ts           # SSG endpoint
│   ├── layouts/
│   │   ├── Layout.astro             # SEO complet, Header + Footer
│   │   └── LegalLayout.astro        # wrapper prose pour pages légales
│   ├── components/
│   │   ├── SiteHeader.tsx           # nav publique
│   │   ├── SiteFooter.tsx
│   │   ├── sections/                # 10 sections de la landing
│   │   └── blog/PostCard.tsx
│   ├── lib/api.ts                   # client API typé (fetch /api/v1/posts)
│   ├── styles/app.css               # @import @rubis/ui/styles + @source
│   └── public/                      # favicons, manifest

Mécanique du changelog (release workflow)

À chaque release, deux fichiers doivent être modifiés en même temps, dans le même commit :

  1. apps/web/src/version.ts — bump de la constante APP_VERSION (semver, sans préfixe v)
  2. apps/landing/src/content/changelog/<APP_VERSION>.md — nouvelle entrée Markdown avec frontmatter Zod-validé (version, date, title, type, highlights[])

Le composant <VersionToast/> monté au root de la SPA (apps/web/src/routes/__root.tsx) compare APP_VERSION à localStorage["rubis:last-seen-version"] au mount :

  • Clé absente (1re visite) → on enregistre APP_VERSION en silence, pas de toast
  • Clé identique → rien
  • Clé différente → toast Sonner persistant avec action Voir les nouveautés ↗ qui ouvre https://rubis.pro/changelog#<APP_VERSION> dans un nouvel onglet. localStorage est mis à jour à l'affichage (donc l'user ne reverra plus le toast pour cette version même s'il ferme sans cliquer).

Pour automatiser le ritual des deux fichiers, le skill Claude Code /push (cf. .claude/skills/push/SKILL.md) bump la version, crée le .md correspondant, commit avec un message conventional, et push sur gitea/main.

Connexion à l'API

En SSR, Astro fetch directement le service Adonis via le DNS K3s (interne, pas de TLS) — cf. API_URL dans le ConfigMap rubis-landing-config :

API_URL=http://rubis-api.rubis.svc.cluster.local:3333

En dev local : API_URL=http://localhost:3333 dans .env.development.


5. packages/shared — types et schémas partagés

Le but : un client API typé fortement, sans duplication de définitions.

// packages/shared/src/types/invoice.ts
export type InvoiceStatus = 'pending' | 'awaiting_user_confirmation' | 'paid' | 'in_relance' | 'litigation' | 'cancelled'

export type Invoice = {
  id: string
  numero: string
  clientId: string
  amountTtc: number
  dueDate: string  // ISO
  status: InvoiceStatus
  planId: string | null
  // ...
}
// packages/shared/src/schemas/invoice.ts
import { z } from 'zod'

export const createInvoiceSchema = z.object({
  numero: z.string().min(1),
  clientId: z.string().uuid(),
  amountTtc: z.number().positive(),
  dueDate: z.string().datetime(),
  // ...
})

export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>

Avantage : Adonis valide avec ce schéma, le SPA valide avec le même, le type est inféré une seule fois.


5bis. packages/ui — design system partagé

Source unique de vérité pour la marque visuelle, consommée par apps/web (et bientôt apps/landing).

packages/ui/
├── src/
│   ├── styles/
│   │   ├── tokens.css         # @theme Tailwind v4 : palette, fonts, radius, shadows
│   │   └── base.css           # @layer base + @utility (eyebrow, shadow-rubis…)
│   ├── components/
│   │   ├── Brand.tsx          # Lockup ◆ + "Rubis sur l'ongle"
│   │   ├── Gem.tsx            # SVG gem facetté direction A
│   │   ├── Button.tsx         # Variants : primary/secondary/ghost/link/danger × sm/md/lg
│   │   ├── Card.tsx           # Variants : default/flat/hero × paddings
│   │   ├── Chip.tsx           # Pastille sélectionnable (filtres, choix tonalité)
│   │   ├── Eyebrow.tsx        # Petit label majuscule rubis avec ◆ préfixe
│   │   └── EmptyState.tsx     # Message centré + icon + cta
│   ├── lib/cn.ts              # Helper Tailwind merge (clsx + tailwind-merge)
│   └── index.ts               # Barrel : exports nommés uniquement
├── package.json               # peerDeps : react, react-dom, lucide-react
└── tsconfig.json              # extends ../../tsconfig.base.json

Conventions :

  • Pas de build step : on exporte directement les .ts/.tsx (cohérent avec packages/shared). Vite/tsc résout les imports en mode source.
  • Tokens en CSS : Tailwind v4 lit @theme {} et génère automatiquement les classes (bg-rubis, font-display, rounded-card…). Pour ajouter une couleur, c'est dans tokens.css, nulle part ailleurs.
  • Consommation :
    /* dans le CSS racine de l'app */
    @import "@rubis/ui/styles/tokens.css";
    @import "@rubis/ui/styles/base.css";
    @source "../../../packages/ui/src/**/*.{ts,tsx}";  /* scan des composants */
    
    import { Button, Card, Brand } from "@rubis/ui";
    
  • peerDeps : react, react-dom, lucide-react. Le consommateur fournit ces deps (évite la duplication de bundle).
  • Ce qui RESTE app-spécifique (apps/web/src/components/) : layouts (AppLayout, AppSidebar), formulaires liés à TanStack Form, composants couplés à des hooks d'auth/router.

6. Flux de données critiques

6.1 Upload + OCR + création facture

SPA (drag & drop PDF)
   │
   │ POST /api/v1/invoices/upload (multipart)
   ▼
api: stocke le PDF dans MinIO (bucket rubis-invoices)
   │
   │ retourne { uploadId, status: 'processing' }
   ▼
api: enqueue job ProcessOcr(uploadId)
   │
   ▼
worker: récupère le PDF depuis MinIO
   │
   │ appel HTTP vers OCR provider
   ▼
worker: parse les champs extraits, crée l'Invoice en DB (status: pending)
   │
   ▼
SPA: poll ou WebSocket → reçoit l'Invoice prête à valider

Points d'attention : le job OCR doit être idempotent (même uploadId rejoué = pas de duplicate). Le SPA peut afficher un spinner pendant les 3-10 secondes d'OCR.

6.1bis Édition native d'une facture (V1.1)

Source secondaire pour les utilisateurs sans outil de facturation existant. Voir ADR-025 pour le rationale, docs/flow.md §11bis pour le flow produit.

SPA (/factures/nouvelle, éditeur split-view)
   │
   │ POST /api/v1/invoices/preview-pdf (body JSON, debounce 500 ms)
   ▼
api: validate, compute totals, render @react-pdf/renderer → Buffer
   │
   │ stream application/pdf
   ▼
SPA: Blob → URL.createObjectURL → iframe src
   │
   │ utilisateur clique "Émettre"
   │ POST /api/v1/invoices/native (draft: false)
   ▼
api: tx{ SELECT … FOR UPDATE organizations.invoice_settings
         → allocate next sequence number
         → snapshot client + issuer (figés à l'émission)
         → INSERT invoices (is_native=true, lines, snapshots, theme_slug…)
       }
   │
   │ post-commit
   ▼
api: renderInvoiceToBuffer(themeSlug, props) → uploadBuffer → MinIO
   │
   │ UPDATE invoices SET pdf_storage_key = '…' WHERE id = …
   ▼
api: schedule check-in (si plan associé)
   │
   ▼
SPA: navigate vers /factures/:id

Points d'attention :

  • Numérotation atomique : SELECT FOR UPDATE sur la ligne organizations sérialise les storeNative concurrents pour la même org. Pas de gaps possibles (les brouillons ne consomment pas la séquence). Conforme art. 242 nonies A du CGI.
  • Snapshots immuables : client_snapshot et issuer_snapshot figés à l'émission. Modifier le client ou les settings post-émission n'altère pas la facture (preuve comptable).
  • Échec de la génération PDF : la facture est créée en DB malgré tout (log warning). Le PDF est régénéré lazy au prochain GET /invoices/:id/pdf (idempotent : on retente puis on persiste la storageKey).
  • Templates partagés : 4 composants TSX dans apps/api/app/pdf-templates/ (Classique, Moderne, Minimal, Élégant) + dispatcher index.tsx. Tous consomment le même InvoiceTemplateProps (cf. common.tsx) — ajouter un thème = créer un nouveau composant et mapper le slug dans THEMES.
  • Roadmap Factur-X : le pipeline renderInvoiceToBuffer est un point d'extension Buffer → Buffer. V1.5 ajoutera l'injection d'un XML CII en pièce jointe PDF/A-3 sans toucher aux templates eux-mêmes.

6.2 Programmation des relances

SPA: utilisateur clique "Valider" sur l'Invoice
   │
   │ PATCH /api/v1/invoices/:id (status: scheduled, planId: …)
   ▼
api: créé N RelanceTasks (une par étape du plan)
       chaque RelanceTask a un sendAt (calculé d'après dueDate + offset étape)
   │
   ▼
queue: tâches en attente
   │
   │ ┌─ avant chaque relance, créer aussi un CheckinTask (T-2j) ─┐
   ▼ ▼                                                          ▼
worker @ sendAt: vérifie l'état de l'Invoice (toujours pending ?)
   │
   │ si invoice.status === 'pending' → envoie l'email
   │ sinon → no-op (l'invoice a été marquée payée entre-temps)

6.3 Check-in email à l'utilisateur

worker @ checkinTask.sendAt:
   │
   │ génère un token signé (avec invoice.id + reply_action: 'paid' | 'not_paid')
   ▼
api: envoie un email à l'utilisateur (pas au client) avec 2 boutons
   │
   │ chaque bouton = lien GET /api/v1/checkin/:token (action embeddée)
   ▼
utilisateur clique "Oui, j'ai été payé"
   │
   ▼
api: GET /checkin/:token → vérifie token, marque invoice.status = 'paid'
                          → annule les RelanceTasks futures de cette invoice
                          → redirect SPA avec confirmation

Sécurité : le token doit être signé (HMAC ou JWT court) et avoir une durée limitée (24h après émission). Pas d'auth Bearer requise pour ce endpoint car c'est un click depuis email.

6.4 Authentification Bearer

SPA: POST /api/v1/auth/login { email, password }
   │
   │ api valide credentials, crée AccessToken (TTL 30 min) + RefreshToken (TTL 30j httpOnly cookie)
   ▼
SPA reçoit { accessToken, user } — accessToken stocké en mémoire
   │
   │ chaque requête API : Authorization: Bearer <accessToken>
   ▼
30 min plus tard : 401 sur appel API
   │
   ▼
SPA: POST /api/v1/auth/refresh (cookie httpOnly envoyé auto)
   │
   │ api valide refresh, émet nouvel accessToken
   ▼
SPA retry l'appel original avec nouveau token

7. Topologie de déploiement

Réseau Proxmox

Resource Type Rôle
Cluster K3s Pool VMs Proxmox Orchestration des pods app
LXC postgres LXC dédié PostgreSQL — accessible aux pods K3s via réseau interne
LXC minio LXC dédié MinIO — accessible aux pods K3s via réseau interne
Traefik Reverse proxy TLS termination + routing par hostname

Pods K3s

# Namespace: rubis (cf. k3s/app/*.yml)
- Deployment: rubis-api          # AdonisJS Node, port 3333 (workers BullMQ inline, pas de pod séparé)
- Deployment: rubis-web          # nginx, sert le bundle Vite + proxy /api → rubis-api:3333, port 80
- Deployment: rubis-landing      # Astro 6 SSR Node, port 4321
- Deployment: rubis-redis        # Redis 7 + PVC pour BullMQ
- Service: rubis-api             # NodePort 30100 → 3333
- Service: rubis-web             # NodePort 30110 → 80
- Service: rubis-landing         # NodePort 30111 → 4321
- Service: rubis-redis           # ClusterIP 6379
- ConfigMap: rubis-api-config + rubis-landing-config + rubis-web-config
- Secret: rubis-api-secrets      # DB credentials, S3 credentials, OCR/Mistral API key, Resend key, Stripe keys
- Routing Traefik (config dynamique sur la VM gateway, repo proxmox) :
    rubis.pro       → 10.10.10.5:30111  (rubis-landing)
    app.rubis.pro/* → 10.10.10.5:30110  (rubis-web — qui proxie /api/* vers rubis-api en interne K3s)
    # Note : pas de hostname `api.rubis.pro` exposé. L'API est servie via app.rubis.pro/api/*.

Ressources externes (LXC Proxmox, hors K3s) : PostgreSQL (PG_HOST=10.10.10.3 en clair dans rubis-api-config), MinIO (DNS minio.minio.svc.cluster.local:9000 via le namespace MinIO du même cluster, bucket prod = rubis-prod-invoices). Pas de Service ExternalName créé, les URLs sont posées directement dans la ConfigMap.

Pipeline CI Gitea

git push gitea main
     ↓
.gitea/workflows/build.yml
     ↓
build & push images :
  - git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
  - git.arthurbarre.fr/ordinarthur/rubis-web:<sha>
     ↓
kubectl rollout (api + web)
     ↓
healthchecks readinessProbe → service public

8. Conventions de code

Domaine Convention
Branches feat/<short-desc>, fix/<short-desc>, chore/<…>
Commits Conventional Commits (feat:, fix:, chore:, docs:, refactor:)
TypeScript strict: true, pas de any (sauf justifié + commenté), noUncheckedIndexedAccess: true
Linting ESLint + Prettier, config partagée via tsconfig.base.json + .eslintrc.cjs racine
Tests Japa (Adonis) pour API, Vitest pour shared/, Playwright pour E2E utilisateur (V2)
Migrations Versionnées, jamais éditées rétroactivement (cf. principe ADR du log de décisions)
Secrets Jamais en clair dans le repo. .env.local git-ignoré, secrets K3s pour la prod

9. Sécurité & RGPD

  • Hébergement français (Proxmox France) — conforme RGPD pour la cible TPE-PME
  • Chiffrement at-rest : disque Proxmox chiffré (LUKS) — à confirmer côté infra
  • Chiffrement in-transit : TLS partout (Traefik), connexions PG et MinIO en SSL interne
  • Rate limiting : @adonisjs/limiter sur /auth/* (5 req/min par IP), routes OCR (10/h par utilisateur)
  • CORS : whitelist stricte (app.rubis.pro uniquement) — refus des origines tierces
  • CSRF : non-applicable car auth via Bearer header (pas cookie session). Les endpoints email check-in utilisent des tokens signés à TTL court.
  • Backups :
    • PG : dump quotidien dans MinIO (rubis-backups/pg/<date>.dump)
    • MinIO : snapshot Proxmox du LXC quotidien
    • Retention : 30 jours mini (à confirmer)
  • Suppression : RGPD Article 17 — endpoint DELETE /api/v1/me qui purge data utilisateur + factures + pièces jointes

10. Décisions encore en attente

À trancher avant fin V1, par ordre de priorité :

# Sujet Statut
019 Domain model (entités, relations, index) tranché — modèles dans apps/api/app/models/
020 Provider OCR tranché — Mistral (OCR_PROVIDER=mistral, cf. .env.example + k3s/app/api.yml). ADR à formaliser
021 Provider email tranché — Resend pour le sortant, OVH MX pour l'entrant (cf. docs/tech/backend.md §12.5). ADR à formaliser
022 Pricing exact tranché — Free 5 factures / Pro 19 € / Business 49 € (cf. apps/api/app/services/billing.ts:34)
023 Endpoint waitlist obsolète — la landing pousse directement vers signup, pas de waitlist en prod
024 Sentry pour error tracking tranché — voir docs/decisions.md ADR-024

11. Évolutions V2+ anticipées

  • Multi-utilisateurs : tables organizations et memberships à prévoir dès la V1 (même si UI mono-user)
  • SMS : provider Twilio/OVH abstrait derrière un service MessageDispatcher qui route email/sms selon plan + cadence
  • Intégration banking : webhook entrant sur /api/v1/banking/payment-confirmed qui marque les invoices payées automatiquement (le check-in email V1 devient fallback)
  • Intégrations comptables (Pennylane/Sage) : modèle d'événement abstrait invoice.created exportable en webhook sortant
  • API publique : sous /api/v1/public/* avec abilities/scopes par token (lecture seule, écriture limitée)

Maintenu par Arthur + Claude. Ce document est versionné — les changements significatifs passent par un ADR dans /docs/decisions.md.