Compare commits

..

6 Commits

Author SHA1 Message Date
ordinarthur
b81bc2609b fix(parametres): teaser banking l'emporte sur upsell + section démo admin-only
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 42s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m26s
Build & Deploy API / build-and-deploy (push) Successful in 2m10s
Deux bugs visibles dans /parametres :

1. **Banque** — un user Free voyait la carte "Plan Pro ou Business
   requis" alors que la feature est gated derrière BANKING_ENABLED=false
   en prod (Powens KYC en cours). L'upgrade n'aurait rien débloqué.
   Fix : la branche `comingSoon` court-circuite l'upsell, et le titre +
   description de la SettingsSection bascule en mode teaser ("Bientôt :
   votre banque connectée à Rubis") pour rester cohérent avec la carte
   "Bientôt disponible" en dessous.

2. **Démonstration** — la section apparaissait pour tous les users,
   alors que c'est un outil de prospection commerciale réservé aux
   admins Rubis (horloge virtuelle + capture des emails). Déroutant
   pour un user lambda.
   Fix : section gated sur `user.isAdmin` côté UI, et split des routes
   /demo côté API :
   - GET /demo/state reste accessible à tous les users authed (sinon le
     DemoClock dans AppLayout spam des 403 sur chaque page). Un user
     normal reçoit `{active: false}` — pas de leak.
   - GET /demo/inbox + POST /demo/start, /end, /tick : auth + admin.
     Mutations et lecture des emails capturés.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:57:37 +02:00
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
ordinarthur
aa6468e9a0 feat(web): éditeur de factures /factures/nouvelle (Phase 4)
Page split-view qui permet de composer une facture native dans Rubis
avec preview PDF en live (debounce 500 ms via POST /invoices/preview-pdf
→ Blob → objectURL → iframe).

UI
- Gauche : panneau d'édition organisé en cards (destinataire,
  dates + plan, lignes éditables, thème + accent, notes).
- Droite : iframe sticky qui affiche le PDF rendu côté serveur. Loader
  discret pendant la génération, fallback "sélectionnez un client" tant
  qu'on n'a pas un payload minimal valide.
- Lignes : ajout/suppression, quantité décimale (heures, demi-jours),
  taux TVA selon FRENCH_TVA_RATES, total HT recalculé live.
- Totaux client-side : mêmes règles d'arrondi (Math.round par ligne)
  que invoice_totals.ts côté serveur — feedback instantané, le serveur
  recalcule à la persistance.
- Footer sticky : "Enregistrer en brouillon" / "Émettre la facture",
  avec rappel que l'émission consomme la séquence (irréversible).

API client
- `useCreateNativeInvoice` : POST /invoices/native, invalide les caches
  invoices + counts.
- `previewInvoicePdf(input, signal)` : POST /invoices/preview-pdf qui
  retourne un Blob (annulable via AbortSignal pour les frappes rapides).
- `api.postBlob` : helper générique POST+JSON → Blob (inverse de fetchBlob).

Defaults : les settings résolus de l'org (theme, accent, paymentTermsDays)
sont chargés une fois au mount et appliqués comme valeurs initiales.

Liste factures : remplace le bouton "Nouvelle facture" par deux actions
côte-à-côte — "Importer" (secondaire, mène à /factures/import) et
"Créer une facture" (primaire, mène à /factures/nouvelle).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:07:41 +02:00
ordinarthur
0680bb9f77 feat(web): page /parametres/facturation — paramétrage de la facturation (Phase 3)
Ajoute la page de configuration de l'éditeur de factures natif côté SPA,
plus les hooks React Query pour /organizations/me/invoice-settings et
/invoice-themes.

Sections (chacune avec son propre Save → blast radius clair) :
1. Identité émetteur (raison sociale, forme juridique, adresse structurée,
   SIREN/SIRET/TVA intracom, NAF, RCS, capital, contact). Snapshotée
   à chaque émission dans `invoices.issuer_snapshot` — modifier ces
   champs n'altère pas les factures déjà émises.
2. RIB (IBAN normalisé à l'enregistrement, BIC, nom de banque).
3. Numérotation avec aperçu live "FAC-2026-0042" — préfixe + prochain
   numéro + padding éditables. Une fois la première facture émise, le
   compteur s'auto-incrémente.
4. Mentions & délais — délai de paiement par défaut + textes légaux
   préremplis (pénalités art. L441-10, escompte art. L441-9, libre).
5. Thème par défaut + couleur d'accent — galerie 4 thèmes avec previews
   miniatures CSS (pas de PDF embed pour la galerie : trop lourd).

Ajoute aussi le lien vers /parametres/facturation depuis /parametres
(section "Facturation" placée avant "Marque").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:32:07 +02:00
ordinarthur
ab07cd4a3b feat(invoices): génération PDF native via @react-pdf/renderer (Phase 2)
Implémente les 4 thèmes de factures (Classique, Moderne, Minimal,
Élégant) en composants React PDF et remplace les stubs Phase 1 par la
vraie génération + upload MinIO.

Templates (app/pdf-templates/)
- common.tsx : props partagées, formatters fr-FR (cents → euros,
  dates longues, taux TVA), palette neutre.
- classique : sobre, header texte centré, filets fins. Pour les
  cabinets et professions réglementées.
- moderne : bandeau coloré pleine largeur, logo dans le bandeau.
  Pour les agences et studios.
- minimal : noir et blanc, aéré, accent uniquement sur le numéro.
  Pour les indépendants et les designers.
- elegant : Times Roman, filets fins, titre centré encadré, italique
  sur le pied légal. Pour les boutiques premium.
- index.tsx : dispatcher slug → composant + renderInvoiceToBuffer.

Génération
- media_storage : nouveau scope `invoice-pdf` (`invoices/<orgId>/<uuid>.pdf`)
  et fonction `uploadBuffer(buffer, scope, subPath?)` pour stocker les
  buffers générés en mémoire (vs. uploads multipart existants).
- invoice_pdf : `generateInvoicePdf` rend + upload, `previewInvoicePdf`
  rend en Buffer pour stream HTTP direct.
- InvoicesController.pdf : lazy regenerate si pdf_storage_key est null
  sur une facture native (cas où la génération initiale a échoué).
- InvoicesController.previewPdf : synthétise un clientSnapshot depuis
  les données live, passe dans le pipeline standard.
- InvoicesController.storeNative : appelle la vraie génération en
  post-commit, log + continue si échec.

Conformité Factur-X (V1.5) : la structure de génération est un
point d'extension Buffer → Buffer ; l'injection d'un XML CII en
pièce jointe PDF sera ajoutée sans toucher aux templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:16:45 +02:00
ordinarthur
e0b47ddfdc feat(invoices): éditeur de factures natif — data model + API (Phase 1)
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.

Data model
- organizations.invoice_settings (JSONB) : thème par défaut, accent color,
  préfixe et compteur de numérotation, mentions légales (pénalités,
  escompte), identité émetteur (SIREN/SIRET/TVA intra/RCS/capital), RIB.
- clients enrichi : SIREN, TVA intra, adresse structurée (lines/zip/city
  /country). Le champ address legacy reste pour les clients pré-feature.
- invoices enrichi : lines (JSONB), client_snapshot + issuer_snapshot
  figés à l'émission, amount_ht/tva, tva_breakdown, payment_terms_days,
  theme_slug + theme_accent_color, is_native, sequence_number (unique
  per org), pdf_generated_at.

API
- GET/PATCH /organizations/me/invoice-settings (resolveInvoiceSettings)
- GET /invoice-themes (4 thèmes : classique, moderne, minimal, élégant)
- POST /invoices/native (séquence strict allouée en transaction,
  totaux recalculés serveur, snapshots immuables)
- POST /invoices/preview-pdf (stream PDF sans persister, stub Phase 1)

Le rendu PDF lui-même (@react-pdf/renderer + templates) arrive en
Phase 2 ; le storeNative crée bien la facture mais pdf_storage_key
reste null jusqu'à Phase 2. Conformité Factur-X visée pour V1.5
(Q3-Q4 2026, avant l'échéance d'émission TPE-PME au 1er sept 2027).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:07:45 +02:00
53 changed files with 5266 additions and 58 deletions

View File

@ -24,7 +24,7 @@ TPE-PME françaises, 5 à 50 salariés, qui émettent 10 à 200 factures par moi
1. **3 clics maximum** pour lancer une relance sur une facture neuve. Idéalement 2 si bien configuré. 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é. 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. 3. **La relance reste l'âme du produit** — c'est notre cœur de promesse. L'**édition native de factures**, ajoutée en V1.1 (cf. ADR-025), est une *extension douce* pour les utilisateurs sans outil de facturation existant. On reste sous-positionnés vs les vrais outils (Pennylane, Sellsy), pas concurrents frontaux. On ne fait toujours pas CRM ni comptabilité.
4. **Respectueux du client final** — le ton monte avec le retard, jamais avant. Pas d'agressivité par défaut. 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. 5. **Le rubis est une vraie devise produit** — 1 rubis = 10 min libérées. La gamification doit être tangible et défendable.
@ -57,6 +57,10 @@ Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associ
- **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme"). - **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme").
- **Confirmation** *(anciennement « 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. - **Confirmation** *(anciennement « 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. - **Mise en demeure** : étape ferme du plan. **Toujours sous validation manuelle** via modale de confirmation, jamais auto.
- **Facture native** : facture **créée dans Rubis via l'éditeur** `/factures/nouvelle` (vs. facture importée par OCR/saisie manuelle). PDF généré côté serveur via `@react-pdf/renderer`, snapshots client + émetteur immuables figés à l'émission. Drapeau `invoices.is_native = true`.
- **Numéro de séquence** : compteur strict séquentiel par organisation (`invoices.sequence_number`), alloué à l'émission d'une facture native via verrou row-level. Conforme art. 242 nonies A du CGI (chronologie continue). Le numéro affiché est `<prefix><seq padé>` (ex. `FAC-2026-0042`). Brouillons exclus du compteur.
- **Snapshot** : copie figée des données du client (`client_snapshot`) et de l'émetteur (`issuer_snapshot`) au moment de l'émission d'une facture native. Garantit l'immutabilité légale : la facture reste intacte même si le client change d'adresse ou si l'org modifie ses settings.
- **Factur-X** : format de facturation électronique mixte PDF/A-3 + XML CII embarqué, conforme à la réforme française B2B (obligation d'émission au 1er septembre 2027 pour TPE-PME). Roadmap V1.5 — pas en V1.
- **DSO** : Days Sales Outstanding. Métrique secondaire dans l'app, jamais dans la com publique. - **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€. - **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€.
@ -68,6 +72,7 @@ Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associ
- Onboarding 3 étapes (compte, entreprise, signature email) - Onboarding 3 étapes (compte, entreprise, signature email)
- Upload drag-and-drop + OCR factures (PDF, PNG, JPG) - Upload drag-and-drop + OCR factures (PDF, PNG, JPG)
- Saisie manuelle (fallback) - Saisie manuelle (fallback)
- **Édition native de factures** (V1.1) — éditeur `/factures/nouvelle` avec lignes structurées, 4 thèmes pré-faits (Classique, Moderne, Minimal, Élégant), couleur d'accent paramétrable, génération PDF côté serveur via `@react-pdf/renderer`. Settings de facturation sur `/parametres/facturation` (identité émetteur, RIB, mentions légales, numérotation strict séquentielle). PDF classique en V1, **Factur-X visé en V1.5** (Q3-Q4 2026), avant l'échéance d'émission TPE-PME au 1er sept 2027. Détails dans `/docs/produit.md` et ADR-025.
- Bibliothèque de plans (4 plans fournis par défaut : *Standard B2B*, *Rapide*, *Patient*, *Ferme*) - Bibliothèque de plans (4 plans fournis par défaut : *Standard B2B*, *Rapide*, *Patient*, *Ferme*)
- Éditeur de plan (cadence + templates email avec variables) - Éditeur de plan (cadence + templates email avec variables)
- Confirmation par email à l'utilisateur (cadence configurable) → confirme si payé → relance ou stop. *Anciennement « check-in ».* - Confirmation par email à l'utilisateur (cadence configurable) → confirme si payé → relance ou stop. *Anciennement « check-in ».*
@ -110,6 +115,8 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
- Mise en demeure : validation manuelle obligatoire (modale) - Mise en demeure : validation manuelle obligatoire (modale)
- SMS et multi-users : V2 + plans payants seulement - SMS et multi-users : V2 + plans payants seulement
- Banking intégration : pas en V1, remplacée par check-in emails - Banking intégration : pas en V1, remplacée par check-in emails
- **Édition native de factures** : extension douce (V1.1), pas pivot vers facturation complète. Conformité Factur-X visée en V1.5, PDP partenaire évaluée en V2 si demandes clients (cf. ADR-025).
- **Numérotation strict séquentielle** : compteur par org alloué en transaction (verrou row-level), brouillons exclus du compteur — choix vs flexible motivé par art. 242 nonies A du CGI.
## Stack technique ## Stack technique
@ -123,6 +130,7 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
| Hosting | **Proxmox + K3s** (perso) | ADR-014 | | Hosting | **Proxmox + K3s** (perso) | ADR-014 |
| OCR provider | à benchmarker | ADR-020 (en attente) | | OCR provider | à benchmarker | ADR-020 (en attente) |
| Email outbound | à benchmarker | ADR-021 (en attente) | | Email outbound | à benchmarker | ADR-021 (en attente) |
| **Génération PDF (factures natives)** | **`@react-pdf/renderer`** côté API (Node), 4 templates dans `apps/api/app/pdf-templates/` | ADR-025 |
**Architecture** : monorepo Turborepo (`apps/api` AdonisJS, `apps/web` React SaaS, `apps/landing` Astro public, `packages/shared` types/schemas, `packages/ui` design system). API REST Bearer-auth, deux frontends qui consomment `@rubis/ui` pour un brand visuel unifié, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`. **Architecture** : monorepo Turborepo (`apps/api` AdonisJS, `apps/web` React SaaS, `apps/landing` Astro public, `packages/shared` types/schemas, `packages/ui` design system). API REST Bearer-auth, deux frontends qui consomment `@rubis/ui` pour un brand visuel unifié, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
@ -141,8 +149,11 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
| `/apps/landing/public/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon | | `/apps/landing/public/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
| `/apps/landing/public/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) | | `/apps/landing/public/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
| `/packages/ui/` | Design system partagé (tokens Tailwind v4 + composants TSX) | | `/packages/ui/` | Design system partagé (tokens Tailwind v4 + composants TSX) |
| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing) | | `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing). Inclut la section "Édition native des factures". |
| `/docs/flow.md` | **Comportement produit deep-dive** : cycle de vie d'une facture, statuts + transitions, surfaces UI, mécanique de confirmation (check-in), mode démo, edge cases | | `/docs/flow.md` | **Comportement produit deep-dive** : cycle de vie d'une facture, statuts + transitions, surfaces UI, mécanique de confirmation (check-in), mode démo, edge cases. Flow native = lignes structurées + snapshots immuables. |
| `/apps/api/app/pdf-templates/` | 4 templates `@react-pdf/renderer` (Classique, Moderne, Minimal, Élégant) + dispatcher. Génération PDF native côté serveur. |
| `/apps/web/src/routes/_app/parametres_.facturation.tsx` | Page de paramétrage de l'éditeur de factures (identité émetteur, RIB, mentions, numérotation, thème par défaut). |
| `/apps/web/src/routes/_app/factures_.nouvelle.tsx` | Éditeur split-view : édition à gauche, preview PDF live à droite (debounce 500 ms). |
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) | | `/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/decisions.md` | Log de décisions avec rationale (format ADR-light) |
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP | | `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |

View File

@ -176,6 +176,17 @@ export default class ClientsController {
phone: payload.phone ?? null, phone: payload.phone ?? null,
address: payload.address ?? null, address: payload.address ?? null,
siret: payload.siret ?? null, siret: payload.siret ?? null,
// Champs structurés pour l'éditeur de factures natif. Lus en cast :
// le schema.ts auto-généré n'expose les colonnes qu'après migration:run.
...({
siren: payload.siren ?? null,
tvaIntra: payload.tvaIntra ?? null,
addressLine1: payload.addressLine1 ?? null,
addressLine2: payload.addressLine2 ?? null,
addressZip: payload.addressZip ?? null,
addressCity: payload.addressCity ?? null,
addressCountry: payload.addressCountry ?? null,
} as Record<string, unknown>),
notes: payload.notes ?? null, notes: payload.notes ?? null,
}) })

View File

@ -0,0 +1,81 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import Organization from '#models/organization'
import {
type InvoiceSettings,
resolveInvoiceSettings,
mergeInvoiceSettings,
validateInvoiceSettings,
normalizeIban,
} from '#services/invoice_settings'
import { updateInvoiceSettingsValidator } from '#validators/invoice_settings'
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
}
/**
* InvoiceSettingsController paramétrage de la facturation par org.
*
* Routes (toutes sous /api/v1/organizations/me/invoice-settings, auth requise) :
* - GET / settings courants + valeurs résolues (defaults appliqués)
* - PATCH / maj partielle des settings (null = reset au default)
*
* Pas de gating de plan : toute org peut paramétrer sa facturation. Le
* gating porte sur la création (`canCreateInvoices`).
*
* Le PATCH applique la sémantique "null = reset au default" : envoyer
* `{ accentColor: null }` retire l'override sur ce champ (l'accent
* repart en brand.primary ou rubis #9F1239).
*/
export default class InvoiceSettingsController {
async show({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
const org = await Organization.findOrFail(orgId)
const settings = (org.invoiceSettings ?? null) as InvoiceSettings | null
return response.json({
data: {
settings: settings ?? {},
resolved: resolveInvoiceSettings(org),
},
})
}
async update({ auth, request, response }: HttpContext) {
const orgId = requireOrgId(auth)
const org = await Organization.findOrFail(orgId)
const payload = await request.validateUsing(updateInvoiceSettingsValidator)
// Cast vers le shape applicatif : Vine retourne un objet typé strict que
// mergeInvoiceSettings peut consommer directement.
const patch = payload as Partial<InvoiceSettings>
// Normalise l'IBAN (suppression espaces + uppercase) avant stockage.
if (patch.rib?.iban) {
patch.rib = { ...patch.rib, iban: normalizeIban(patch.rib.iban) }
}
const err = validateInvoiceSettings(patch)
if (err) {
return response.status(422).json({ errors: [{ message: err }] })
}
const current = (org.invoiceSettings ?? null) as InvoiceSettings | null
const merged = mergeInvoiceSettings(current, patch)
org.invoiceSettings = Object.keys(merged).length === 0 ? null : merged
await org.save()
return response.json({
data: {
settings: org.invoiceSettings ?? {},
resolved: resolveInvoiceSettings(org),
},
})
}
}

View File

@ -0,0 +1,50 @@
import type { HttpContext } from '@adonisjs/core/http'
/**
* InvoiceThemesController galerie des thèmes disponibles pour l'éditeur.
*
* Route (auth requise) :
* - GET /api/v1/invoice-themes liste les thèmes pré-faits
*
* Le rendu lui-même (composants React PDF) vit dans
* `packages/ui/invoice-templates/<slug>.tsx`. Cet endpoint ne retourne que
* les métadonnées (slug + name + description) pour peupler la galerie de
* sélection dans /parametres/facturation et l'éditeur /factures/nouvelle.
*
* Note : pas de pagination, pas de filtre la liste est petite (4 thèmes
* en V1) et entièrement statique côté serveur. Si on ajoute des thèmes
* payants (Pro/Business) plus tard, on filtrera ici selon `org.plan`.
*/
const INVOICE_THEMES = [
{
slug: 'classique',
name: 'Classique',
description:
'Sobre et sérieux, header texte centré. Pour les cabinets et professions réglementées.',
},
{
slug: 'moderne',
name: 'Moderne',
description:
'Bandeau coloré en header, typo Bricolage. Pour les agences et studios.',
},
{
slug: 'minimal',
name: 'Minimal',
description:
'Noir et blanc, aéré, aucun ornement. Pour les indépendants et les designers.',
},
{
slug: 'elegant',
name: 'Élégant',
description:
'Filets fins, watermark logo discret. Pour les boutiques premium et lartisanat.',
},
] as const
export default class InvoiceThemesController {
async index({ response }: HttpContext) {
return response.json({ data: INVOICE_THEMES })
}
}

View File

@ -1,8 +1,15 @@
import Invoice from '#models/invoice' import Invoice from '#models/invoice'
import Client from '#models/client'
import Organization from '#models/organization'
import Plan from '#models/plan' import Plan from '#models/plan'
import RelanceTask from '#models/relance_task' import RelanceTask from '#models/relance_task'
import InvoiceTransformer from '#transformers/invoice_transformer' import InvoiceTransformer from '#transformers/invoice_transformer'
import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice' import {
createInvoiceValidator,
listInvoicesValidator,
createNativeInvoiceValidator,
previewInvoiceValidator,
} from '#validators/invoice'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions' import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
@ -13,6 +20,10 @@ import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler' import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher' import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
import { canCreateInvoices } from '#services/billing' import { canCreateInvoices } from '#services/billing'
import { allocateNextInvoiceNumber } from '#services/invoice_numbering'
import { computeInvoiceTotals } from '#services/invoice_totals'
import { resolveInvoiceSettings } from '#services/invoice_settings'
import { generateInvoicePdf, previewInvoicePdf } from '#services/invoice_pdf'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
import * as clock from '#services/clock' import * as clock from '#services/clock'
import drive from '@adonisjs/drive/services/main' import drive from '@adonisjs/drive/services/main'
@ -342,12 +353,17 @@ export default class InvoicesController {
} }
/** /**
* GET /invoices/:id/pdf stream le PDF/image originel de la facture. * GET /invoices/:id/pdf stream le PDF (généré ou uploadé) de la facture.
* *
* Source : `pdfStorageKey` propagé depuis le draft d'import lors de la * Cas couverts :
* validation. 404 si la facture n'a pas de fichier (saisie manuelle). * - Facture OCR/manuelle (`pdfStorageKey` propagé du draft) stream tel quel.
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob * - Facture native déjà rendue (`pdfStorageKey` non-null) stream depuis MinIO.
* puis affiche dans un <iframe>/<img> via objectURL. * - Facture native avec génération échouée (`isNative=true` + `pdfStorageKey=null`)
* lazy regenerate à la volée, persiste, puis stream.
* - Facture sans fichier (saisie manuelle pré-feature, jamais native) 404.
*
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob puis
* affiche dans un <iframe>/<img> via objectURL.
*/ */
async pdf({ auth, params, response }: HttpContext) { async pdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth) const organizationId = requireOrgId(auth)
@ -359,6 +375,29 @@ export default class InvoicesController {
if (!invoice) { if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' }) throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
} }
// Lazy regenerate : facture native dont la génération a échoué → on retente.
if (!invoice.pdfStorageKey && invoice.isNative) {
try {
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
const generated = await generateInvoicePdf({
invoice,
resolvedSettings,
organization: org,
})
invoice.pdfStorageKey = generated.storageKey
invoice.pdfGeneratedAt = DateTime.utc()
await invoice.save()
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'lazy invoice pdf regeneration failed')
throw new Exception('Impossible de générer le PDF de la facture', {
status: 500,
code: 'pdf_generation_failed',
})
}
}
if (!invoice.pdfStorageKey) { if (!invoice.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour cette facture', { throw new Exception('Aucun PDF stocké pour cette facture', {
status: 404, status: 404,
@ -443,4 +482,227 @@ export default class InvoicesController {
return response.json({ data: serializeInvoice(invoice) }) return response.json({ data: serializeInvoice(invoice) })
} }
/**
* POST /invoices/native création depuis l'éditeur natif.
*
* Diffère de `store` (saisie manuelle / OCR) sur 3 points :
* - numéro alloué par le serveur (séquence strict, art. 242 nonies A CGI)
* - lignes structurées + recalcul serveur de tous les totaux (HT/TVA/TTC)
* - snapshot du client et de l'émetteur figés à l'émission (immutabilité
* légale : une facture émise ne doit jamais changer rétroactivement)
*
* Mode brouillon (`draft: true`) : ne consomme pas la séquence, status =
* `pending` avec sequence_number=null et numero éphémère "BROUILLON-XXX".
* Re-POST sans draft = émet pour de bon.
*/
async storeNative({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createNativeInvoiceValidator)
const enforcement = await canCreateInvoices(organizationId, 1)
if (!enforcement.allowed) {
throw new Exception(
`Limite atteinte : ${enforcement.limit} factures actives sur le plan Free. Passez Pro pour créer cette facture.`,
{ status: 402, code: 'plan_limit_reached' }
)
}
// Recalcul serveur des totaux — on n'a pas confiance dans le client.
const totals = computeInvoiceTotals(payload.lines)
const invoice = await db.transaction(async (trx) => {
// Vérifie l'appartenance du client à l'org.
const client = await Client.query({ client: trx })
.where('organization_id', organizationId)
.where('id', payload.clientId)
.first()
if (!client) {
throw new Exception('Client introuvable pour cette organisation', {
status: 422,
code: 'client_not_found',
})
}
// Vérifie le plan s'il est fourni.
let planId: string | null = null
if (payload.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', payload.planId)
.first()
if (plan) planId = plan.id
}
// Snapshots immuables figés au moment de l'émission.
const org = await Organization.findOrFail(organizationId, { client: trx })
const resolvedSettings = resolveInvoiceSettings(org)
const issuerSnapshot = { ...resolvedSettings.issuer }
const clientSnapshot = {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName,
contactLastName: client.contactLastName,
phone: client.phone,
siret: client.siret,
siren: (client as unknown as { siren: string | null }).siren ?? null,
tvaIntra: (client as unknown as { tvaIntra: string | null }).tvaIntra ?? null,
addressLine1: (client as unknown as { addressLine1: string | null }).addressLine1 ?? null,
addressLine2: (client as unknown as { addressLine2: string | null }).addressLine2 ?? null,
addressZip: (client as unknown as { addressZip: string | null }).addressZip ?? null,
addressCity: (client as unknown as { addressCity: string | null }).addressCity ?? null,
addressCountry:
(client as unknown as { addressCountry: string | null }).addressCountry ?? null,
}
// Allocation du numéro (consomme la séquence sauf si draft).
const allocated = await allocateNextInvoiceNumber(organizationId, trx, {
draft: payload.draft ?? false,
})
const created = await Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: allocated.numero,
sequenceNumber: allocated.sequenceNumber,
amountTtcCents: totals.amountTtcCents,
amountHtCents: totals.amountHtCents,
amountTvaCents: totals.amountTvaCents,
tvaBreakdown: totals.tvaBreakdown,
lines: totals.lines,
issueDate: DateTime.fromISO(payload.issueDate),
dueDate: DateTime.fromISO(payload.dueDate),
paymentTermsDays: payload.paymentTermsDays,
status: 'pending',
themeSlug: payload.themeSlug,
themeAccentColor: payload.accentColor,
clientSnapshot,
issuerSnapshot,
footerNotes: payload.footerNotes ?? null,
isNative: true,
rubisEarned: 1,
pdfStorageKey: null,
pdfGeneratedAt: null,
notes: null,
paidAt: null,
} as Partial<Invoice>,
{ client: trx }
)
return created
})
await invoice.load('client')
await invoice.load('plan')
// Génération du PDF en post-commit. Échec = on log et on continue, la
// facture est créée et le PDF sera régénérable plus tard (idempotent).
try {
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
const generated = await generateInvoicePdf({
invoice,
resolvedSettings,
organization: org,
})
invoice.pdfStorageKey = generated.storageKey
invoice.pdfGeneratedAt = DateTime.utc()
await invoice.save()
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'native invoice pdf generation failed')
}
// Programme le check-in (envoyé à dueDate) — même mécanique que `store`.
if (!(payload.draft ?? false)) {
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/preview-pdf preview d'un PDF sans persister.
*
* Mêmes champs que `storeNative` (sauf `draft`) le serveur recalcule
* les totaux et stream le PDF (`application/pdf`). Utilisé par l'éditeur
* pour afficher le rendu dans un `<iframe>` (debounced 500ms côté UI
* pour éviter le spam de requêtes pendant la saisie).
*/
async previewPdf({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(previewInvoiceValidator)
// Vérifie l'appartenance du client à l'org (sécurité : pas de leak inter-org).
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', payload.clientId)
.first()
if (!client) {
throw new Exception('Client introuvable pour cette organisation', {
status: 422,
code: 'client_not_found',
})
}
const totals = computeInvoiceTotals(payload.lines)
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
// Synthétise un clientSnapshot à partir du client live (pas encore figé
// puisque la facture n'est pas émise).
const clientSnapshotForPreview = {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName,
contactLastName: client.contactLastName,
phone: client.phone,
siret: client.siret,
siren: (client as unknown as { siren: string | null }).siren ?? null,
tvaIntra: (client as unknown as { tvaIntra: string | null }).tvaIntra ?? null,
addressLine1: (client as unknown as { addressLine1: string | null }).addressLine1 ?? null,
addressLine2: (client as unknown as { addressLine2: string | null }).addressLine2 ?? null,
addressZip: (client as unknown as { addressZip: string | null }).addressZip ?? null,
addressCity: (client as unknown as { addressCity: string | null }).addressCity ?? null,
addressCountry:
(client as unknown as { addressCountry: string | null }).addressCountry ?? null,
}
// Invoice "virtuel" non-persisté pour passer dans le pipeline de rendu.
const virtualInvoice = new Invoice()
virtualInvoice.organizationId = organizationId
virtualInvoice.clientId = client.id
virtualInvoice.numero = '[APERÇU]'
virtualInvoice.sequenceNumber = null
virtualInvoice.amountTtcCents = totals.amountTtcCents
virtualInvoice.amountHtCents = totals.amountHtCents
virtualInvoice.amountTvaCents = totals.amountTvaCents
virtualInvoice.tvaBreakdown = totals.tvaBreakdown
virtualInvoice.lines = totals.lines
virtualInvoice.issueDate = DateTime.fromISO(payload.issueDate)
virtualInvoice.dueDate = DateTime.fromISO(payload.dueDate)
virtualInvoice.paymentTermsDays = payload.paymentTermsDays
virtualInvoice.themeSlug = payload.themeSlug
virtualInvoice.themeAccentColor = payload.accentColor
virtualInvoice.footerNotes = payload.footerNotes ?? null
virtualInvoice.isNative = true
virtualInvoice.clientSnapshot = clientSnapshotForPreview
virtualInvoice.issuerSnapshot = resolvedSettings.issuer
const pdf = await previewInvoicePdf({
invoice: virtualInvoice,
resolvedSettings,
organization: org,
})
response.header('Content-Type', 'application/pdf')
response.header('Cache-Control', 'no-store')
return response.send(pdf)
}
} }

View File

@ -1,10 +1,41 @@
import { ClientSchema } from '#database/schema' import { ClientSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm' import { belongsTo, column, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization' import Organization from '#models/organization'
import Invoice from '#models/invoice' import Invoice from '#models/invoice'
export default class Client extends ClientSchema { export default class Client extends ClientSchema {
/**
* Champs ajoutés par la migration `1778800000100_enrich_clients_for_invoicing`
* (SIREN/TVA intra/adresse structurée). Déclarations manuelles en attendant
* que `schema.ts` soit régénéré par `node ace migration:run`.
*
* Le champ `address` (existant, string libre) est conservé pour les clients
* importés avant la feature ; le nouveau code lit en priorité ces champs
* structurés et retombe sur `address` s'ils sont vides.
*/
@column()
declare siren: string | null
@column()
declare tvaIntra: string | null
@column()
declare addressLine1: string | null
@column()
declare addressLine2: string | null
@column()
declare addressZip: string | null
@column()
declare addressCity: string | null
/** ISO 3166-1 alpha-2 (ex. "FR"). */
@column()
declare addressCountry: string | null
@belongsTo(() => Organization) @belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization> declare organization: BelongsTo<typeof Organization>

View File

@ -1,11 +1,84 @@
import { InvoiceSchema } from '#database/schema' import { InvoiceSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm' import { belongsTo, column } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations' import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import type { DateTime } from 'luxon'
import Organization from '#models/organization' import Organization from '#models/organization'
import Client from '#models/client' import Client from '#models/client'
import Plan from '#models/plan' import Plan from '#models/plan'
import type {
InvoiceIssuer,
InvoiceThemeSlug,
} from '#services/invoice_settings'
import type {
ComputedInvoiceLine,
TvaBreakdownItem,
} from '#services/invoice_totals'
/**
* Snapshot du client figé au moment de l'émission. Permet à la facture
* de rester intacte si le client change d'adresse ou de raison sociale.
*/
export interface ClientSnapshot {
name: string
email: string
contactFirstName: string | null
contactLastName: string | null
phone: string | null
siret: string | null
siren: string | null
tvaIntra: string | null
addressLine1: string | null
addressLine2: string | null
addressZip: string | null
addressCity: string | null
addressCountry: string | null
}
export default class Invoice extends InvoiceSchema { export default class Invoice extends InvoiceSchema {
/**
* Champs ajoutés par la migration `1778800000200_enrich_invoices_for_native_editor`.
* Déclarations manuelles en attendant que `schema.ts` soit régénéré par
* `node ace migration:run`.
*/
@column()
declare lines: ComputedInvoiceLine[] | null
@column()
declare clientSnapshot: ClientSnapshot | null
@column()
declare issuerSnapshot: InvoiceIssuer | null
@column()
declare amountHtCents: number | null
@column()
declare amountTvaCents: number | null
@column()
declare tvaBreakdown: TvaBreakdownItem[] | null
@column()
declare paymentTermsDays: number | null
@column()
declare footerNotes: string | null
@column()
declare themeSlug: InvoiceThemeSlug | null
@column()
declare themeAccentColor: string | null
@column()
declare isNative: boolean
@column()
declare sequenceNumber: number | null
@column.dateTime()
declare pdfGeneratedAt: DateTime | null
@belongsTo(() => Organization) @belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization> declare organization: BelongsTo<typeof Organization>

View File

@ -4,6 +4,7 @@ import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user' import User from '#models/user'
import BankConnection from '#models/bank_connection' import BankConnection from '#models/bank_connection'
import type { BrandSettings } from '#services/brand' import type { BrandSettings } from '#services/brand'
import type { InvoiceSettings } from '#services/invoice_settings'
export default class Organization extends OrganizationSchema { export default class Organization extends OrganizationSchema {
/** /**
@ -16,6 +17,16 @@ export default class Organization extends OrganizationSchema {
@column() @column()
declare brandSettings: BrandSettings | null declare brandSettings: BrandSettings | null
/**
* Settings de facturation native JSONB, null = defaults applicatifs.
* Cf. `#services/invoice_settings` pour la résolution et la validation.
* Cette déclaration manuelle existe en attendant que `schema.ts` soit
* régénéré par `node ace migration:run` (cf. migration
* `1778800000000_add_invoice_settings_to_organizations_table.ts`).
*/
@column()
declare invoiceSettings: InvoiceSettings | null
@hasMany(() => User) @hasMany(() => User)
declare users: HasMany<typeof User> declare users: HasMany<typeof User>

View File

@ -0,0 +1,289 @@
/**
* Template "Classique" sobre, sérieux, header texte centré.
*
* Cible : cabinets, professions réglementées, structures traditionnelles.
* Pas de bandeau coloré, pas d'ornementation. L'accent color est utilisé
* uniquement pour les filets de séparation et le numéro de facture.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
daysBetween,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 56,
paddingBottom: 56,
paddingHorizontal: 56,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.5,
},
// Header
headerCenter: { textAlign: 'center', marginBottom: 24 },
logo: { width: 80, height: 32, objectFit: 'contain', marginBottom: 8, alignSelf: 'center' },
companyName: { fontSize: 16, fontWeight: 'bold', marginBottom: 4 },
companyMeta: { fontSize: 9, color: PALETTE.ink2 },
divider: { borderBottomWidth: 1, marginVertical: 16 },
// Titre facture
invoiceBlock: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
invoiceTitle: { fontSize: 22, fontWeight: 'bold' },
invoiceMeta: { fontSize: 10 },
invoiceMetaRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
invoiceMetaLabel: { color: PALETTE.ink2 },
// Client
clientBlock: {
borderWidth: 1,
borderColor: PALETTE.line,
padding: 12,
marginBottom: 20,
width: '50%',
alignSelf: 'flex-end',
},
clientLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 4 },
clientName: { fontSize: 11, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
borderBottomWidth: 1,
paddingBottom: 6,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, fontWeight: 'bold' },
// Totals
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
marginTop: 4,
borderTopWidth: 1,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 12, fontWeight: 'bold' },
grandAmount: { fontSize: 12, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: { flexDirection: 'row', borderBottomWidth: 0.5, borderBottomColor: PALETTE.line, paddingBottom: 3 },
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.4,
},
})
export function ClassiqueTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const dividerAccent = { borderBottomColor: props.accentColor }
const grandAccent = { borderTopColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header centré : logo + identité émetteur */}
<View style={styles.headerCenter}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`addr-${i}`} style={styles.companyMeta}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.companyMeta}>SIRET {props.issuer.siret}</Text>
) : null}
{props.issuer.tvaIntra ? (
<Text style={styles.companyMeta}>TVA {props.issuer.tvaIntra}</Text>
) : null}
</View>
<View style={[styles.divider, dividerAccent]} />
{/* Bloc facture (gauche) + meta (droite) */}
<View style={styles.invoiceBlock}>
<View>
<Text style={[styles.invoiceTitle, accent]}>FACTURE</Text>
<Text style={styles.invoiceMeta}>N° {props.numero}</Text>
</View>
<View style={{ minWidth: 180 }}>
<View style={styles.invoiceMetaRow}>
<Text style={styles.invoiceMetaLabel}>Date d'émission</Text>
<Text>{formatDate(props.issueDate)}</Text>
</View>
<View style={styles.invoiceMetaRow}>
<Text style={styles.invoiceMetaLabel}>Date d'échéance</Text>
<Text>{formatDate(props.dueDate)}</Text>
</View>
<View style={styles.invoiceMetaRow}>
<Text style={styles.invoiceMetaLabel}>Délai</Text>
<Text>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''}
</Text>
</View>
</View>
</View>
{/* Client */}
<View style={styles.clientBlock}>
<Text style={styles.clientLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`} style={styles.companyMeta}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.companyMeta}>SIRET {props.client.siret}</Text>
) : null}
{props.client.tvaIntra ? (
<Text style={styles.companyMeta}>TVA {props.client.tvaIntra}</Text>
) : null}
</View>
{/* Table des lignes */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{/* Ventilation TVA — affichée seulement si plusieurs taux */}
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, grandAccent]}>
<Text style={[styles.grandLabel, accent]}>Total TTC</Text>
<Text style={[styles.grandAmount, accent]}>
{formatCents(props.amountTtcCents)}
</Text>
</View>
</View>
{/* Paiement / RIB */}
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
{/* Pied légal */}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
<Text>
Échéance : {formatDate(props.dueDate)} (
{daysBetween(props.issueDate, props.dueDate)} jours)
</Text>
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,148 @@
/**
* pdf-templates/common types, formatters, et helpers partagés entre
* les 4 thèmes de factures Rubis.
*
* Les thèmes consomment tous le même `InvoiceTemplateProps`. Le dispatcher
* (`#pdf-templates/index`) sélectionne le bon composant selon le slug.
*
* Convention :
* - Tous les montants en *centimes* (int). On formate au moment du render
* via `formatCents(cents)` (sépare millier + ", " + " €").
* - Toutes les dates en `DateTime` Luxon on formate via `formatDate(d)`
* en français long ("15 mai 2026").
*/
import { DateTime } from 'luxon'
import type {
InvoiceIssuer,
InvoiceThemeSlug,
} from '#services/invoice_settings'
import type {
ComputedInvoiceLine,
TvaBreakdownItem,
} from '#services/invoice_totals'
import type { ClientSnapshot } from '#models/invoice'
/**
* Props passées à chaque template. C'est le contrat figé que respectent
* tous les thèmes ajouter un champ = mettre à jour les 4 composants.
*/
export interface InvoiceTemplateProps {
/** Métadonnées en-tête : numéro, dates, paiement. */
numero: string
issueDate: DateTime
dueDate: DateTime
paymentTermsDays: number
/** Émetteur (snapshot figé à l'émission). */
issuer: InvoiceIssuer
/** Client destinataire (snapshot figé à l'émission). */
client: ClientSnapshot
/** Lignes calculées (HT par ligne déjà arrondi). */
lines: ComputedInvoiceLine[]
/** Ventilation TVA — affichée seulement si plusieurs taux. */
tvaBreakdown: TvaBreakdownItem[]
/** Totaux agrégés (en centimes). */
amountHtCents: number
amountTvaCents: number
amountTtcCents: number
/** Mentions légales (snapshot des settings au moment de l'émission). */
penaltyRateText: string
escompteText: string
footerLegalText: string
/** Notes libres en pied de page (custom par facture). */
footerNotes: string | null
/** RIB pour le pied de page paiement. */
rib: {
iban: string | null
bic: string | null
bankName: string | null
}
/** Couleur d'accent appliquée (hex #RRGGBB). */
accentColor: string
/** URL absolue du logo (null = pas de logo). */
logoUrl: string | null
}
// ============================================================================
// Formatters (format français)
// ============================================================================
/**
* Formate des centimes en string "X 123,45 €". Pas de décimales sans virgule
* (toujours 2 chiffres après la virgule, exigence comptable).
*/
export function formatCents(cents: number): string {
const euros = cents / 100
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(euros)
}
/** Format français long : "15 mai 2026". */
export function formatDate(d: DateTime): string {
return d.setLocale('fr').toFormat('d MMMM yyyy')
}
/** Format quantité : 2 décimales si non-entier, sinon int. */
export function formatQuantity(q: number): string {
return Number.isInteger(q)
? String(q)
: new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(q)
}
/** Format taux TVA : "20 %" ou "5,5 %". */
export function formatTvaRate(rate: number): string {
return Number.isInteger(rate) ? `${rate} %` : `${rate.toString().replace('.', ',')} %`
}
/**
* Formate l'adresse postale en multi-lignes utilisable dans le PDF.
* Retourne un tableau de strings non-vides (le template fait un map Text).
*/
export function formatAddress(parts: {
line1: string | null | undefined
line2: string | null | undefined
zip: string | null | undefined
city: string | null | undefined
country: string | null | undefined
}): string[] {
const out: string[] = []
if (parts.line1) out.push(parts.line1)
if (parts.line2) out.push(parts.line2)
if (parts.zip || parts.city) {
out.push([parts.zip, parts.city].filter(Boolean).join(' '))
}
// Pays affiché uniquement hors France (par convention factures domestiques).
if (parts.country && parts.country !== 'FR') out.push(parts.country)
return out
}
/** Calcule le nombre de jours entre deux dates (issue vs due) pour affichage. */
export function daysBetween(from: DateTime, to: DateTime): number {
return Math.round(to.diff(from, 'days').days)
}
// ============================================================================
// Palette commune
// ============================================================================
/** Tons de gris cohérents avec la palette Rubis (cream + ink). */
export const PALETTE = {
ink: '#1A1410',
ink2: '#4F4640',
ink3: '#8A7F76',
line: '#E8E0D6',
cream: '#FAF7F2',
paper: '#FFFFFF',
} as const
// ============================================================================
// Dispatcher type — réexporté pour le sélecteur côté `index.tsx`
// ============================================================================
export type { InvoiceThemeSlug }

View File

@ -0,0 +1,286 @@
/**
* Template "Élégant" filets fins, mise en page éditoriale.
*
* Cible : boutiques premium, artisanat haut de gamme, hôtellerie-restauration
* qualitative. Filets de séparation horizontaux discrets, deux colonnes
* pour l'identité, accent color sur les filets et le mot "Facture" centré.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 56,
paddingBottom: 56,
paddingHorizontal: 64,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Times-Roman',
lineHeight: 1.55,
},
// Header deux colonnes
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
headerLeft: { flex: 1 },
headerRight: { flex: 1, alignItems: 'flex-end' },
logo: { width: 64, height: 26, objectFit: 'contain', marginBottom: 10 },
companyName: { fontSize: 12, fontWeight: 'bold', marginBottom: 2 },
small: { fontSize: 9, color: PALETTE.ink2 },
// Titre centré
titleBlock: { alignItems: 'center', marginVertical: 16 },
titleAccent: { fontSize: 9, textTransform: 'uppercase', letterSpacing: 4, marginBottom: 6 },
titleText: { fontSize: 24, fontWeight: 'bold', fontFamily: 'Times-Bold' },
titleNumero: { fontSize: 11, marginTop: 4, color: PALETTE.ink2, fontStyle: 'italic' },
hairline: { borderBottomWidth: 0.5, marginVertical: 12 },
// Méta
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24 },
metaBlock: { flex: 1 },
metaLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 4 },
metaValue: { fontSize: 11 },
clientName: { fontSize: 12, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
borderBottomWidth: 1,
paddingBottom: 6,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 1 },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 10,
paddingBottom: 4,
marginTop: 6,
borderTopWidth: 1,
borderBottomWidth: 0.5,
paddingHorizontal: 4,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 13, fontWeight: 'bold' },
grandAmount: { fontSize: 13, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
paddingBottom: 3,
},
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.5,
fontStyle: 'italic',
},
})
export function ElegantTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const hairlineAccent = { borderBottomColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header : émetteur à gauche, dates à droite */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`a-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.small}>SIRET {props.issuer.siret}</Text>
) : null}
{props.issuer.tvaIntra ? (
<Text style={styles.small}>TVA {props.issuer.tvaIntra}</Text>
) : null}
</View>
<View style={styles.headerRight}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={styles.metaValue}>{formatDate(props.issueDate)}</Text>
<Text style={[styles.metaLabel, { marginTop: 6 }]}>Échéance</Text>
<Text style={styles.metaValue}>{formatDate(props.dueDate)}</Text>
</View>
</View>
{/* Titre centré */}
<View style={[styles.hairline, hairlineAccent]} />
<View style={styles.titleBlock}>
<Text style={[styles.titleAccent, accent]}>Facture</Text>
<Text style={styles.titleText}>N° {props.numero}</Text>
<Text style={styles.titleNumero}>
Établie le {formatDate(props.issueDate)}
</Text>
</View>
<View style={[styles.hairline, hairlineAccent]} />
{/* Méta client */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.small}>SIRET {props.client.siret}</Text>
) : null}
{props.client.tvaIntra ? (
<Text style={styles.small}>TVA {props.client.tvaIntra}</Text>
) : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Délai de paiement</Text>
<Text style={styles.metaValue}>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''}
</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, hairlineAccent, { borderTopColor: props.accentColor }]}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={[styles.grandAmount, accent]}>
{formatCents(props.amountTtcCents)}
</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,51 @@
/**
* pdf-templates dispatcher : sélectionne le bon thème et rend en Buffer.
*
* Le code applicatif (`#services/invoice_pdf`) appelle `renderInvoiceToBuffer`
* qui :
* 1. Choisit le composant React selon `themeSlug`
* 2. Le rend en PDF via `@react-pdf/renderer.renderToBuffer`
* 3. Retourne le Buffer prêt à être uploadé sur MinIO ou streamé
*
* Les 4 thèmes consomment tous le même `InvoiceTemplateProps` (cf. common.tsx),
* ce qui permet d'ajouter ou de remplacer un thème sans toucher au dispatcher.
*/
import React from 'react'
import { renderToBuffer, type DocumentProps } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
type InvoiceThemeSlug,
} from '#pdf-templates/common'
import { ClassiqueTemplate } from '#pdf-templates/classique'
import { ModerneTemplate } from '#pdf-templates/moderne'
import { MinimalTemplate } from '#pdf-templates/minimal'
import { ElegantTemplate } from '#pdf-templates/elegant'
/** Mapping slug → composant. Source de vérité du dispatcher. */
const THEMES: Record<
InvoiceThemeSlug,
(props: InvoiceTemplateProps) => React.ReactElement<DocumentProps>
> = {
classique: ClassiqueTemplate,
moderne: ModerneTemplate,
minimal: MinimalTemplate,
elegant: ElegantTemplate,
}
/**
* Rend la facture en PDF (Buffer).
*
* @param themeSlug Slug du thème fallback "classique" si inconnu (defensive).
* @param props Données passées au template (cf. InvoiceTemplateProps).
*/
export async function renderInvoiceToBuffer(
themeSlug: InvoiceThemeSlug,
props: InvoiceTemplateProps
): Promise<Buffer> {
const Template = THEMES[themeSlug] ?? THEMES.classique
const element = Template(props)
return await renderToBuffer(element)
}
export type { InvoiceTemplateProps, InvoiceThemeSlug }

View File

@ -0,0 +1,230 @@
/**
* Template "Minimal" noir et blanc, aéré, aucun ornement.
*
* Cible : indépendants, designers, profils qui veulent un rendu Apple-clean.
* L'accent color n'est utilisé que pour le numéro de facture (sobre).
* Pas de filets, beaucoup d'espace blanc, typographie en valeur.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 72,
paddingBottom: 60,
paddingHorizontal: 72,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.6,
},
// Header
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 56 },
headerLeft: { flex: 1 },
headerRight: { flex: 1, alignItems: 'flex-end' },
logo: { width: 48, height: 18, objectFit: 'contain', marginBottom: 12 },
invoiceLabel: { fontSize: 9, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 2 },
invoiceNumero: { fontSize: 18, marginTop: 4 },
companyName: { fontSize: 11, fontWeight: 'bold' },
small: { fontSize: 9, color: PALETTE.ink2 },
// Méta
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 48 },
metaBlock: { flex: 1 },
metaLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 6 },
metaValue: { fontSize: 11 },
// Table
table: { marginBottom: 24 },
tableHeader: { flexDirection: 'row', paddingBottom: 8 },
tableRow: { flexDirection: 'row', paddingVertical: 8 },
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 1 },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 32 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 12,
marginTop: 8,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 14, fontWeight: 'bold' },
grandAmount: { fontSize: 14, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 24, width: '60%' },
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 16, marginBottom: 16 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 24,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.5,
},
})
export function MinimalTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header sobre : logo + numéro grand */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`a-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.small}>SIRET {props.issuer.siret}</Text>
) : null}
</View>
<View style={styles.headerRight}>
<Text style={styles.invoiceLabel}>Facture</Text>
<Text style={[styles.invoiceNumero, accent]}>{props.numero}</Text>
</View>
</View>
{/* Méta : client + dates */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Client</Text>
<Text style={styles.metaValue}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`c-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.small}>SIRET {props.client.siret}</Text>
) : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={styles.metaValue}>{formatDate(props.issueDate)}</Text>
<Text style={[styles.metaLabel, { marginTop: 8 }]}>Échéance</Text>
<Text style={styles.metaValue}>{formatDate(props.dueDate)}</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>TVA {formatTvaRate(b.rate)} sur</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={styles.totalRowGrand}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={styles.grandAmount}>{formatCents(props.amountTtcCents)}</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Paiement</Text>
{props.rib.bankName ? <Text style={styles.small}>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text style={styles.small}>IBAN {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text style={styles.small}>BIC {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.small}>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,269 @@
/**
* Template "Moderne" bandeau coloré en header, mise en page contemporaine.
*
* Cible : agences, studios, freelances créatifs. L'accent color est dominant :
* bandeau header, ligne de séparation, total TTC. Le logo se pose sur le
* bandeau.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.5,
},
// Bandeau coloré pleine largeur en haut
banner: {
paddingTop: 36,
paddingBottom: 28,
paddingHorizontal: 56,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
bannerLeft: { flex: 2 },
bannerRight: { flex: 1, alignItems: 'flex-end' },
invoiceTitle: { fontSize: 26, fontWeight: 'bold', color: PALETTE.paper, letterSpacing: 1 },
invoiceNumero: { fontSize: 11, color: PALETTE.paper, opacity: 0.85, marginTop: 4 },
bannerCompany: { fontSize: 13, color: PALETTE.paper, fontWeight: 'bold' },
bannerMeta: { fontSize: 8, color: PALETTE.paper, opacity: 0.85 },
logo: { width: 60, height: 24, objectFit: 'contain', marginBottom: 6 },
// Corps
body: { paddingTop: 32, paddingHorizontal: 56, paddingBottom: 36, flexGrow: 1 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24 },
metaBlock: { flex: 1 },
metaLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
clientName: { fontSize: 12, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
paddingVertical: 8,
paddingHorizontal: 6,
},
tableHeaderText: { fontSize: 8, textTransform: 'uppercase', fontWeight: 'bold', color: PALETTE.paper },
tableRow: {
flexDirection: 'row',
paddingVertical: 8,
paddingHorizontal: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 12,
marginTop: 6,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 12, fontWeight: 'bold', color: PALETTE.paper },
grandAmount: { fontSize: 14, fontWeight: 'bold', color: PALETTE.paper },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
paddingBottom: 3,
},
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, fontWeight: 'bold' },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.4,
},
})
export function ModerneTemplate(props: InvoiceTemplateProps) {
const accentBg = { backgroundColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Bandeau coloré */}
<View style={[styles.banner, accentBg]}>
<View style={styles.bannerLeft}>
<Text style={styles.invoiceTitle}>FACTURE</Text>
<Text style={styles.invoiceNumero}>N° {props.numero}</Text>
</View>
<View style={styles.bannerRight}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.bannerCompany}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.slice(0, 2).map((line, i) => (
<Text key={`bma-${i}`} style={styles.bannerMeta}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.bannerMeta}>SIRET {props.issuer.siret}</Text>
) : null}
</View>
</View>
<View style={styles.body}>
{/* Méta : client + dates */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`}>{line}</Text>
))}
{props.client.siret ? <Text>SIRET {props.client.siret}</Text> : null}
{props.client.tvaIntra ? <Text>TVA {props.client.tvaIntra}</Text> : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={{ marginBottom: 6 }}>{formatDate(props.issueDate)}</Text>
<Text style={styles.metaLabel}>Échéance</Text>
<Text>{formatDate(props.dueDate)}</Text>
<Text style={{ fontSize: 9, color: PALETTE.ink3, marginTop: 2 }}>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''} de
délai
</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={[styles.tableHeader, accentBg]}>
<Text style={[styles.cellDesc, styles.tableHeaderText]}>Désignation</Text>
<Text style={[styles.cellQty, styles.tableHeaderText]}>Qté</Text>
<Text style={[styles.cellPu, styles.tableHeaderText]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.tableHeaderText]}>TVA</Text>
<Text style={[styles.cellTotal, styles.tableHeaderText]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, accentBg]}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={styles.grandAmount}>{formatCents(props.amountTtcCents)}</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</View>
</Page>
</Document>
)
}

View File

@ -0,0 +1,98 @@
/**
* invoice_numbering allocation atomique du prochain numéro de facture.
*
* Stratégie : numérotation strict séquentielle par organisation (exigence
* art. 242 nonies A du CGI : chronologie continue, sans rupture). Le
* compteur d'org `invoice_settings.numeroNextSeq` est lu+incrémenté+saved
* dans une transaction avec verrou explicite, garantissant qu'aucun n°
* n'est attribué deux fois même sous forte concurrence (deux requêtes
* simultanées du même user via deux onglets, par exemple).
*
* Le format affiché est `<prefix><seq padé sur N>` (ex. "FAC-2026-0042").
* Le préfixe et la séquence sont aussi stockés séparément sur la facture
* (`numero` = chaîne formatée, `sequence_number` = entier brut) pour
* permettre le tri SQL natif et la détection des gaps.
*
* Mode brouillon : si `draft = true`, on ne consomme pas la séquence. Le
* `numero` retourné est éphémère ("BROUILLON" + UUID) et `sequenceNumber`
* vaut null. Émettre plus tard = appel sans draft allocation propre.
*/
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Organization from '#models/organization'
import {
resolveInvoiceSettings,
mergeInvoiceSettings,
type InvoiceSettings,
} from '#services/invoice_settings'
import { Exception } from '@adonisjs/core/exceptions'
import { randomUUID } from 'node:crypto'
export interface AllocatedInvoiceNumber {
/** Chaîne formatée affichée et stockée dans `invoices.numero`. */
numero: string
/** Entier brut stocké dans `invoices.sequence_number`. Null si draft. */
sequenceNumber: number | null
}
/**
* Alloue le prochain numéro de facture pour l'org.
*
* @param organizationId UUID de l'org
* @param trx Transaction Lucid DOIT être passée pour que le verrou tienne
* jusqu'au save de la facture. Le caller ouvre `db.transaction(...)`
* et fait `allocateNextInvoiceNumber(orgId, trx)` puis crée la
* facture dans la même transaction.
* @param options.draft Si true, ne consomme pas la séquence (brouillon).
*/
export async function allocateNextInvoiceNumber(
organizationId: string,
trx: TransactionClientContract,
options: { draft?: boolean } = {}
): Promise<AllocatedInvoiceNumber> {
if (options.draft) {
return {
numero: `BROUILLON-${randomUUID().slice(0, 8).toUpperCase()}`,
sequenceNumber: null,
}
}
// Verrou row-level sur la ligne organization pour sérialiser les
// appels concurrents. Postgres : FOR UPDATE bloque les autres SELECT
// FOR UPDATE et UPDATE jusqu'au commit/rollback.
const orgRow = await trx
.from('organizations')
.where('id', organizationId)
.forUpdate()
.select('invoice_settings')
.first()
if (!orgRow) {
throw new Exception('Organisation introuvable', { status: 404, code: 'not_found' })
}
const settings = (orgRow.invoice_settings ?? null) as InvoiceSettings | null
// Charge l'org en mémoire pour brand fallback (mais on n'a pas besoin de la
// verrouiller à nouveau — invoice_settings est déjà locked).
const org = await Organization.find(organizationId, { client: trx })
if (!org) {
throw new Exception('Organisation introuvable', { status: 404, code: 'not_found' })
}
const resolved = resolveInvoiceSettings(org)
const seq = settings?.numeroNextSeq ?? resolved.numeroNextSeq
const padding = settings?.numeroPadding ?? resolved.numeroPadding
const prefix = settings?.numeroPrefix ?? resolved.numeroPrefix
const numero = `${prefix}${String(seq).padStart(padding, '0')}`
// Incrémente le compteur dans le JSONB. mergeInvoiceSettings respecte
// les autres champs, donc on n'écrase rien d'autre.
const nextSettings = mergeInvoiceSettings(settings, { numeroNextSeq: seq + 1 })
await trx
.from('organizations')
.where('id', organizationId)
.update({ invoice_settings: JSON.stringify(nextSettings) })
return { numero, sequenceNumber: seq }
}

View File

@ -0,0 +1,110 @@
/**
* invoice_pdf génération de PDF pour les factures natives.
*
* Phase 2 : implémentation réelle via @react-pdf/renderer + upload MinIO.
*
* Pipeline :
* 1. Construit les `InvoiceTemplateProps` depuis l'invoice + settings résolus
* 2. Appelle `renderInvoiceToBuffer(themeSlug, props)` (dispatcher de thèmes)
* 3. `generateInvoicePdf` upload sur MinIO et retourne la storageKey.
* `previewInvoicePdf` retourne le buffer brut pour stream HTTP.
*
* Note Factur-X (V1.5) : le buffer généré est un PDF/A-3 compatible. Pour
* passer en Factur-X, il faudra injecter un XML CII en pièce jointe dans
* le PDF (cf. roadmap dans CLAUDE.md / decisions.md ADR-022). Ce point
* d'extension est laissé en post-traitement Buffer Buffer.
*/
import type Invoice from '#models/invoice'
import type { ResolvedInvoiceSettings } from '#services/invoice_settings'
import { resolveBrandTokens } from '#services/brand'
import { renderInvoiceToBuffer } from '#pdf-templates/index'
import { uploadBuffer } from '#services/media_storage'
import type { InvoiceTemplateProps } from '#pdf-templates/common'
import type Organization from '#models/organization'
export interface InvoiceRenderContext {
invoice: Invoice
resolvedSettings: ResolvedInvoiceSettings
/** Org de l'invoice — utilisée pour le logo (brand_settings.logoUrl). */
organization: Organization
}
export interface GeneratedPdf {
/** Clé MinIO sous laquelle le PDF est stocké. */
storageKey: string
/** Taille du PDF en bytes. */
bytes: number
}
/**
* Construit les props passées au template à partir d'une facture (potentiellement
* non-persistée, pour la preview) et des settings résolus.
*
* On lit en priorité les snapshots de la facture (`issuerSnapshot`, `clientSnapshot`)
* c'est l'état figé à l'émission. Si les snapshots sont absents (preview avant
* persistance), on retombe sur les settings résolus pour issuer et sur les
* données fournies à la preview pour client (cf. previewPdf controller qui
* construit un Invoice virtuel avec un clientSnapshot synthétisé).
*/
function buildProps(ctx: InvoiceRenderContext): InvoiceTemplateProps {
const { invoice, resolvedSettings, organization } = ctx
const issuer = invoice.issuerSnapshot ?? resolvedSettings.issuer
if (!invoice.clientSnapshot) {
throw new Error(
'invoice_pdf: clientSnapshot manquant — impossible de rendre la facture sans destinataire'
)
}
const brand = resolveBrandTokens(organization)
const logoUrl = brand.logoUrl ?? null
return {
numero: invoice.numero,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
paymentTermsDays: invoice.paymentTermsDays ?? resolvedSettings.paymentTermsDays,
issuer,
client: invoice.clientSnapshot,
lines: invoice.lines ?? [],
tvaBreakdown: invoice.tvaBreakdown ?? [],
amountHtCents: invoice.amountHtCents ?? 0,
amountTvaCents: invoice.amountTvaCents ?? 0,
amountTtcCents: invoice.amountTtcCents,
penaltyRateText: resolvedSettings.penaltyRateText,
escompteText: resolvedSettings.escompteText,
footerLegalText: resolvedSettings.footerLegalText,
footerNotes: invoice.footerNotes,
rib: {
iban: resolvedSettings.rib.iban,
bic: resolvedSettings.rib.bic,
bankName: resolvedSettings.rib.bankName,
},
accentColor: invoice.themeAccentColor ?? resolvedSettings.accentColor,
logoUrl,
}
}
/**
* Génère le PDF et l'upload sur MinIO. Retourne la storageKey à persister
* dans `invoices.pdf_storage_key`.
*/
export async function generateInvoicePdf(
ctx: InvoiceRenderContext
): Promise<GeneratedPdf> {
const themeSlug = ctx.invoice.themeSlug ?? ctx.resolvedSettings.themeSlug
const props = buildProps(ctx)
const buffer = await renderInvoiceToBuffer(themeSlug, props)
const uploaded = await uploadBuffer(buffer, 'invoice-pdf', ctx.invoice.organizationId)
return { storageKey: uploaded.storageKey, bytes: uploaded.sizeBytes }
}
/**
* Génère le PDF sans l'uploader utilisé pour la preview (stream HTTP direct).
*/
export async function previewInvoicePdf(ctx: InvoiceRenderContext): Promise<Buffer> {
const themeSlug = ctx.invoice.themeSlug ?? ctx.resolvedSettings.themeSlug
const props = buildProps(ctx)
return await renderInvoiceToBuffer(themeSlug, props)
}

View File

@ -0,0 +1,274 @@
/**
* invoice_settings résolution des paramètres de facturation d'une org
* pour l'éditeur de factures natif.
*
* Stockage : JSONB `organizations.invoice_settings` (cf. migration
* 1778800000000). Tous les champs sont optionnels, on resolve avec des
* defaults au moment de générer un PDF.
*
* Convention `null` :
* - PATCH avec une clé à `null` explicite reset au default sur ce champ
* - PATCH avec une clé absente laisse intact
* - Cohérent avec brand.ts pour réduire la charge cognitive côté SPA.
*
* Pas de plan gating : toute org peut paramétrer sa facturation. Le gating
* porte sur la création de facture elle-même (`canCreateInvoices`).
*
* Pattern : les types sont déclarés localement (pas d'import depuis
* @rubis/shared) cohérent avec brand.ts et les autres services. Les
* types côté SPA (packages/shared) sont structurellement équivalents.
*/
import type Organization from '#models/organization'
import { resolveBrandTokens } from '#services/brand'
const HEX_RE = /^#[0-9a-fA-F]{6}$/u
const ISO_COUNTRY_RE = /^[A-Z]{2}$/u
const SIREN_RE = /^\d{9}$/u
const SIRET_RE = /^\d{14}$/u
const TVA_INTRA_RE = /^[A-Z]{2}[A-Z0-9]{2,18}$/u
const NAF_RE = /^\d{4}[A-Z]$/u
const IBAN_RE = /^[A-Z0-9 ]{15,40}$/u
const BIC_RE = /^[A-Z0-9]{8}([A-Z0-9]{3})?$/u
export const INVOICE_THEME_SLUGS = ['classique', 'moderne', 'minimal', 'elegant'] as const
export type InvoiceThemeSlug = (typeof INVOICE_THEME_SLUGS)[number]
export interface InvoiceIssuer {
companyName?: string | null
addressLine1?: string | null
addressLine2?: string | null
addressZip?: string | null
addressCity?: string | null
addressCountry?: string | null
siren?: string | null
siret?: string | null
tvaIntra?: string | null
rcs?: string | null
capital?: string | null
formeJuridique?: string | null
naf?: string | null
contactEmail?: string | null
contactPhone?: string | null
}
export interface InvoiceRib {
iban?: string | null
bic?: string | null
bankName?: string | null
}
/** Shape brute du JSONB `organizations.invoice_settings`. */
export interface InvoiceSettings {
themeSlug?: InvoiceThemeSlug
accentColor?: string | null
numeroPrefix?: string | null
numeroNextSeq?: number | null
numeroPadding?: number | null
paymentTermsDays?: number | null
penaltyRateText?: string | null
escompteText?: string | null
footerLegalText?: string | null
issuer?: InvoiceIssuer | null
rib?: InvoiceRib | null
}
/** Settings résolus — ce que les templates PDF consomment. */
export interface ResolvedInvoiceSettings {
themeSlug: InvoiceThemeSlug
accentColor: string
numeroPrefix: string
numeroNextSeq: number
numeroPadding: number
paymentTermsDays: number
penaltyRateText: string
escompteText: string
footerLegalText: string
issuer: Required<{ [K in keyof InvoiceIssuer]: string | null }>
rib: Required<{ [K in keyof InvoiceRib]: string | null }>
}
/** Defaults publics — texte légal aligné sur les exigences du Code de commerce. */
export const DEFAULT_PENALTY_RATE_TEXT =
"En cas de retard de paiement, des pénalités de retard sont exigibles au taux annuel équivalent à trois fois le taux d'intérêt légal. Une indemnité forfaitaire pour frais de recouvrement de 40 € s'applique également (art. D441-5 du Code de commerce)."
export const DEFAULT_ESCOMPTE_TEXT = "Pas d'escompte consenti pour paiement anticipé."
export const DEFAULT_PAYMENT_TERMS_DAYS = 30
export const DEFAULT_NUMERO_PADDING = 4
export const DEFAULT_THEME_SLUG: InvoiceThemeSlug = 'classique'
/**
* Résout les settings effectifs d'une org pour générer un PDF.
*
* - `accentColor` : settings brand.primaryColor rubis #9F1239
* - `issuer.companyName` : settings org.name
* - `issuer.siret` : settings org.siret
* - autres : defaults applicatifs
*/
export function resolveInvoiceSettings(org: Organization): ResolvedInvoiceSettings {
const settings = (org.invoiceSettings ?? null) as InvoiceSettings | null
const brand = resolveBrandTokens(org)
return {
themeSlug: settings?.themeSlug ?? DEFAULT_THEME_SLUG,
accentColor: settings?.accentColor ?? brand.primary,
numeroPrefix: settings?.numeroPrefix ?? '',
numeroNextSeq: settings?.numeroNextSeq ?? 1,
numeroPadding: settings?.numeroPadding ?? DEFAULT_NUMERO_PADDING,
paymentTermsDays: settings?.paymentTermsDays ?? DEFAULT_PAYMENT_TERMS_DAYS,
penaltyRateText: settings?.penaltyRateText ?? DEFAULT_PENALTY_RATE_TEXT,
escompteText: settings?.escompteText ?? DEFAULT_ESCOMPTE_TEXT,
footerLegalText: settings?.footerLegalText ?? '',
issuer: {
companyName: settings?.issuer?.companyName ?? org.name ?? null,
addressLine1: settings?.issuer?.addressLine1 ?? null,
addressLine2: settings?.issuer?.addressLine2 ?? null,
addressZip: settings?.issuer?.addressZip ?? null,
addressCity: settings?.issuer?.addressCity ?? null,
addressCountry: settings?.issuer?.addressCountry ?? 'FR',
siren: settings?.issuer?.siren ?? null,
siret: settings?.issuer?.siret ?? org.siret ?? null,
tvaIntra: settings?.issuer?.tvaIntra ?? null,
rcs: settings?.issuer?.rcs ?? null,
capital: settings?.issuer?.capital ?? null,
formeJuridique: settings?.issuer?.formeJuridique ?? null,
naf: settings?.issuer?.naf ?? null,
contactEmail: settings?.issuer?.contactEmail ?? null,
contactPhone: settings?.issuer?.contactPhone ?? null,
},
rib: {
iban: settings?.rib?.iban ?? null,
bic: settings?.rib?.bic ?? null,
bankName: settings?.rib?.bankName ?? null,
},
}
}
/**
* Merge un patch dans les settings existants pattern identique à
* `mergeBrandSettings` : `null` explicite supprime le champ, `undefined`
* laisse intact. `issuer` et `rib` sont mergés en deep partial.
*/
export function mergeInvoiceSettings(
existing: InvoiceSettings | null,
patch: Partial<InvoiceSettings>
): InvoiceSettings {
const next: InvoiceSettings = { ...(existing ?? {}) }
for (const [key, value] of Object.entries(patch) as [keyof InvoiceSettings, unknown][]) {
if (value === null) {
delete next[key]
continue
}
if (value === undefined) continue
if (key === 'issuer') {
const existingIssuer = (existing?.issuer ?? {}) as InvoiceIssuer
const patchIssuer = value as InvoiceIssuer
const merged: InvoiceIssuer = { ...existingIssuer }
for (const [k, v] of Object.entries(patchIssuer) as [keyof InvoiceIssuer, unknown][]) {
if (v === null) {
delete merged[k]
} else if (v !== undefined) {
;(merged as Record<string, unknown>)[k] = v
}
}
next.issuer = merged
continue
}
if (key === 'rib') {
const existingRib = (existing?.rib ?? {}) as InvoiceRib
const patchRib = value as InvoiceRib
const merged: InvoiceRib = { ...existingRib }
for (const [k, v] of Object.entries(patchRib) as [keyof InvoiceRib, unknown][]) {
if (v === null) {
delete merged[k]
} else if (v !== undefined) {
;(merged as Record<string, unknown>)[k] = v
}
}
next.rib = merged
continue
}
;(next as Record<string, unknown>)[key] = value
}
return next
}
/**
* Valide un patch InvoiceSettings retourne le premier message d'erreur,
* ou null si tout est OK. Vérifications minimales en complément de Vine
* côté validator (les regex sont dupliquées pour blinder le service en
* cas d'appel direct hors HTTP).
*/
export function validateInvoiceSettings(patch: Partial<InvoiceSettings>): string | null {
if (patch.themeSlug !== undefined && patch.themeSlug !== null) {
if (!INVOICE_THEME_SLUGS.includes(patch.themeSlug)) {
return `invalid_theme: doit être l'un de ${INVOICE_THEME_SLUGS.join(', ')}`
}
}
if (patch.accentColor !== undefined && patch.accentColor !== null) {
if (!HEX_RE.test(patch.accentColor)) {
return 'invalid_accent_color: format #RRGGBB attendu'
}
}
if (patch.numeroPadding !== undefined && patch.numeroPadding !== null) {
if (!Number.isInteger(patch.numeroPadding) || patch.numeroPadding < 1 || patch.numeroPadding > 10) {
return 'invalid_numero_padding: entier entre 1 et 10'
}
}
if (patch.numeroNextSeq !== undefined && patch.numeroNextSeq !== null) {
if (!Number.isInteger(patch.numeroNextSeq) || patch.numeroNextSeq < 1) {
return 'invalid_numero_next_seq: entier ≥ 1'
}
}
if (patch.paymentTermsDays !== undefined && patch.paymentTermsDays !== null) {
if (
!Number.isInteger(patch.paymentTermsDays) ||
patch.paymentTermsDays < 0 ||
patch.paymentTermsDays > 365
) {
return 'invalid_payment_terms_days: entier entre 0 et 365'
}
}
if (patch.issuer) {
const { issuer } = patch
if (issuer.addressCountry && !ISO_COUNTRY_RE.test(issuer.addressCountry)) {
return 'invalid_issuer.address_country: code ISO 2 lettres'
}
if (issuer.siren && !SIREN_RE.test(issuer.siren)) {
return 'invalid_issuer.siren: 9 chiffres requis'
}
if (issuer.siret && !SIRET_RE.test(issuer.siret)) {
return 'invalid_issuer.siret: 14 chiffres requis'
}
if (issuer.tvaIntra && !TVA_INTRA_RE.test(issuer.tvaIntra)) {
return 'invalid_issuer.tva_intra: format UE invalide (ex. FR12345678901)'
}
if (issuer.naf && !NAF_RE.test(issuer.naf)) {
return 'invalid_issuer.naf: format NAF/APE invalide (ex. 6201Z)'
}
}
if (patch.rib) {
const { rib } = patch
if (rib.iban && !IBAN_RE.test(rib.iban)) {
return 'invalid_rib.iban: IBAN invalide'
}
if (rib.bic && !BIC_RE.test(rib.bic)) {
return 'invalid_rib.bic: BIC/SWIFT invalide (8 ou 11 caractères)'
}
}
return null
}
/** Normalise un IBAN : majuscules + suppression des espaces. */
export function normalizeIban(iban: string): string {
return iban.replace(/\s+/g, '').toUpperCase()
}

View File

@ -0,0 +1,87 @@
/**
* invoice_totals calcul des totaux d'une facture native depuis ses lignes.
*
* Règles (cohérence comptable, jamais de float) :
* - totalHtCents par ligne = round(quantity × unitPriceCents). On round par
* ligne (et pas seulement sur la somme) parce que c'est ce qui est affiché
* dans le PDF et que la somme des arrondis doit matcher l'affichage.
* - TVA par ligne = round(totalHtCents × tvaRate / 100)
* - amountHtCents = somme des totalHtCents
* - amountTvaCents = somme des TVA par ligne
* - amountTtcCents = amountHtCents + amountTvaCents
* - tvaBreakdown : agrégation par taux (un item par taux distinct)
*
* NE JAMAIS faire confiance au client pour ces totaux c'est une exigence
* comptable (la facture est une preuve, le total doit être recalculable et
* vérifiable). Le SPA peut calculer en local pour l'aperçu, mais le serveur
* recalcule à la persistance.
*/
export interface RawInvoiceLine {
id: string
description: string
quantity: number
unitPriceCents: number
tvaRate: number
}
export interface ComputedInvoiceLine extends RawInvoiceLine {
/** Total HT de la ligne en centimes (toujours entier, arrondi). */
totalHtCents: number
}
export interface TvaBreakdownItem {
rate: number
htCents: number
tvaCents: number
}
export interface ComputedInvoiceTotals {
lines: ComputedInvoiceLine[]
amountHtCents: number
amountTvaCents: number
amountTtcCents: number
tvaBreakdown: TvaBreakdownItem[]
}
function roundCents(value: number): number {
// Math.round avec banker's rounding ? Non — pour la facturation française
// l'usage est l'arrondi à l'unité supérieure pour 0.5. C'est ce que fait
// Math.round (vers +∞ pour les positifs). Les montants sont toujours
// positifs sur une facture, donc Math.round suffit.
return Math.round(value)
}
export function computeInvoiceTotals(lines: RawInvoiceLine[]): ComputedInvoiceTotals {
const computed: ComputedInvoiceLine[] = lines.map((l) => ({
...l,
totalHtCents: roundCents(l.quantity * l.unitPriceCents),
}))
// Agrégation par taux. Map<rate, {ht, tva}>
const byRate = new Map<number, { htCents: number; tvaCents: number }>()
for (const line of computed) {
const lineTvaCents = roundCents((line.totalHtCents * line.tvaRate) / 100)
const existing = byRate.get(line.tvaRate) ?? { htCents: 0, tvaCents: 0 }
existing.htCents += line.totalHtCents
existing.tvaCents += lineTvaCents
byRate.set(line.tvaRate, existing)
}
// Tri par taux croissant pour l'affichage stable dans le PDF.
const tvaBreakdown: TvaBreakdownItem[] = Array.from(byRate.entries())
.sort(([a], [b]) => a - b)
.map(([rate, { htCents, tvaCents }]) => ({ rate, htCents, tvaCents }))
const amountHtCents = tvaBreakdown.reduce((s, b) => s + b.htCents, 0)
const amountTvaCents = tvaBreakdown.reduce((s, b) => s + b.tvaCents, 0)
const amountTtcCents = amountHtCents + amountTvaCents
return {
lines: computed,
amountHtCents,
amountTvaCents,
amountTtcCents,
tvaBreakdown,
}
}

View File

@ -34,7 +34,7 @@ import path from 'node:path'
import drive from '@adonisjs/drive/services/main' import drive from '@adonisjs/drive/services/main'
import type { MultipartFile } from '@adonisjs/core/bodyparser' import type { MultipartFile } from '@adonisjs/core/bodyparser'
export type MediaScope = 'blog' | 'brand-logo' export type MediaScope = 'blog' | 'brand-logo' | 'invoice-pdf'
interface ScopeConfig { interface ScopeConfig {
/** Préfixe de stockage MinIO (clé S3). */ /** Préfixe de stockage MinIO (clé S3). */
@ -62,6 +62,16 @@ const SCOPES: Record<MediaScope, ScopeConfig> = {
allowedExts: ['jpg', 'jpeg', 'png', 'webp', 'svg'], allowedExts: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
maxBytes: 1 * 1024 * 1024, // 1 MB — un logo n'a aucune raison d'être plus gros maxBytes: 1 * 1024 * 1024, // 1 MB — un logo n'a aucune raison d'être plus gros
}, },
'invoice-pdf': {
// Factures natives générées par Rubis (vs. uploads OCR qui restent dans
// un chemin distinct historique géré par l'ImportBatchesController).
// Stockées sous `invoices/<orgId>/<uuid>.pdf` — scope par org pour
// faciliter purge/migration future.
storagePrefix: 'invoices',
urlSegment: 'invoices',
allowedExts: ['pdf'],
maxBytes: 4 * 1024 * 1024, // 4 MB — une facture PDF dépasse rarement 200KB.
},
} }
export interface UploadResult { export interface UploadResult {
@ -157,6 +167,50 @@ export async function deleteMedia(storageKey: string): Promise<void> {
} }
} }
/**
* Upload un Buffer (généré en mémoire, ex. PDF rendu par @react-pdf) sur MinIO.
*
* Différence avec `uploadMedia(MultipartFile)` : pas de tmpPath à `moveFromFs`,
* on écrit directement le buffer via `drive.put`. Utilisé par la génération
* de factures natives (pas de upload depuis le client).
*
* Le caller fournit le `scope` (pour récupérer les contraintes) ET un
* sous-chemin optionnel (`subPath`) pour organiser le stockage. Pour les
* factures, on passe `subPath = orgId` `invoices/<orgId>/<uuid>.pdf`.
*/
export async function uploadBuffer(
buffer: Buffer,
scope: MediaScope,
subPath?: string
): Promise<UploadResult> {
const cfg = SCOPES[scope]
if (!cfg) throw new Error(`unknown_scope: ${scope}`)
if (cfg.allowedExts.length === 0) throw new Error(`scope ${scope} has no allowed extensions`)
// On force la première extension du scope — c'est cohérent avec l'usage
// (un scope = un format). Pour `invoice-pdf` c'est `pdf`.
const ext = cfg.allowedExts[0]
if (buffer.length > cfg.maxBytes) {
throw new Error(`file_too_large: ${buffer.length}B (max ${cfg.maxBytes}B)`)
}
const filename = `${randomUUID()}.${ext}`
const segments = subPath
? [cfg.storagePrefix, subPath, filename]
: [cfg.storagePrefix, filename]
const storageKey = segments.join('/')
await drive.use().put(storageKey, buffer)
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
return {
publicPath: `${apiHost}/api/v1/uploads/${cfg.urlSegment}/${filename}`,
storageKey,
contentType: extToContentType(ext),
sizeBytes: buffer.length,
}
}
function extToContentType(ext: string): string { function extToContentType(ext: string): string {
switch (ext) { switch (ext) {
case 'jpg': case 'jpg':
@ -168,6 +222,8 @@ function extToContentType(ext: string): string {
return 'image/webp' return 'image/webp'
case 'svg': case 'svg':
return 'image/svg+xml' return 'image/svg+xml'
case 'pdf':
return 'application/pdf'
default: default:
return 'application/octet-stream' return 'application/octet-stream'
} }

View File

@ -4,6 +4,17 @@ import { BaseTransformer } from '@adonisjs/core/transformers'
export default class ClientTransformer extends BaseTransformer<Client> { export default class ClientTransformer extends BaseTransformer<Client> {
toObject() { toObject() {
const c = this.resource const c = this.resource
// Champs ajoutés par enrich_clients_for_invoicing — lus en cast tant que
// schema.ts n'est pas régénéré (cf. invoice_transformer pour le pattern).
const enriched = c as unknown as {
siren: string | null
tvaIntra: string | null
addressLine1: string | null
addressLine2: string | null
addressZip: string | null
addressCity: string | null
addressCountry: string | null
}
return { return {
id: c.id, id: c.id,
organizationId: c.organizationId, organizationId: c.organizationId,
@ -14,6 +25,13 @@ export default class ClientTransformer extends BaseTransformer<Client> {
phone: c.phone, phone: c.phone,
address: c.address, address: c.address,
siret: c.siret, siret: c.siret,
siren: enriched.siren ?? null,
tvaIntra: enriched.tvaIntra ?? null,
addressLine1: enriched.addressLine1 ?? null,
addressLine2: enriched.addressLine2 ?? null,
addressZip: enriched.addressZip ?? null,
addressCity: enriched.addressCity ?? null,
addressCountry: enriched.addressCountry ?? null,
notes: c.notes, notes: c.notes,
createdAt: c.createdAt.toISO()!, createdAt: c.createdAt.toISO()!,
updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!, updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!,

View File

@ -4,6 +4,25 @@ import { BaseTransformer } from '@adonisjs/core/transformers'
export default class InvoiceTransformer extends BaseTransformer<Invoice> { export default class InvoiceTransformer extends BaseTransformer<Invoice> {
toObject() { toObject() {
const i = this.resource const i = this.resource
// Les champs ajoutés par la migration `enrich_invoices_for_native_editor`
// sont lus en cast unknown parce que le schema.ts auto-généré ne les
// expose qu'après `node ace migration:run`. Le typage strict revient
// dès que les migrations sont jouées.
const native = i as unknown as {
lines: unknown
clientSnapshot: unknown
issuerSnapshot: unknown
amountHtCents: number | null
amountTvaCents: number | null
tvaBreakdown: unknown
paymentTermsDays: number | null
footerNotes: string | null
themeSlug: string | null
themeAccentColor: string | null
isNative: boolean
sequenceNumber: number | null
pdfGeneratedAt: import('luxon').DateTime | null
}
return { return {
id: i.id, id: i.id,
organizationId: i.organizationId, organizationId: i.organizationId,
@ -13,13 +32,26 @@ export default class InvoiceTransformer extends BaseTransformer<Invoice> {
// dans la table invoice, on préfère le préchargement côté API. // dans la table invoice, on préfère le préchargement côté API.
clientName: i.client?.name ?? '', clientName: i.client?.name ?? '',
numero: i.numero, numero: i.numero,
sequenceNumber: native.sequenceNumber ?? null,
amountTtcCents: i.amountTtcCents, amountTtcCents: i.amountTtcCents,
amountHtCents: native.amountHtCents ?? null,
amountTvaCents: native.amountTvaCents ?? null,
tvaBreakdown: native.tvaBreakdown ?? null,
lines: native.lines ?? null,
paymentTermsDays: native.paymentTermsDays ?? null,
clientSnapshot: native.clientSnapshot ?? null,
issuerSnapshot: native.issuerSnapshot ?? null,
themeSlug: native.themeSlug ?? null,
themeAccentColor: native.themeAccentColor ?? null,
footerNotes: native.footerNotes ?? null,
isNative: !!native.isNative,
issueDate: i.issueDate.toISO()!, issueDate: i.issueDate.toISO()!,
dueDate: i.dueDate.toISO()!, dueDate: i.dueDate.toISO()!,
status: i.status, status: i.status,
planId: i.planId, planId: i.planId,
planName: i.plan?.name ?? null, planName: i.plan?.name ?? null,
pdfStorageKey: i.pdfStorageKey, pdfStorageKey: i.pdfStorageKey,
pdfGeneratedAt: native.pdfGeneratedAt?.toISO() ?? null,
notes: i.notes, notes: i.notes,
rubisEarned: i.rubisEarned, rubisEarned: i.rubisEarned,
paidAt: i.paidAt?.toISO() ?? null, paidAt: i.paidAt?.toISO() ?? null,

View File

@ -2,10 +2,18 @@ import vine from '@vinejs/vine'
const name = () => vine.string().minLength(2).maxLength(120) const name = () => vine.string().minLength(2).maxLength(120)
const email = () => vine.string().email().maxLength(254) const email = () => vine.string().email().maxLength(254)
// SIRET = 14 chiffres exactement (cf. INSEE). // SIRET = 14 chiffres, SIREN = 9 chiffres (cf. INSEE).
const siret = () => vine.string().regex(/^\d{14}$/) const siret = () => vine.string().regex(/^\d{14}$/)
const siren = () => vine.string().regex(/^\d{9}$/)
// TVA intracom UE — FR + 11 chiffres ; les autres pays ont des formats variés
// (DE9, BE10…). On accepte du 4 à 20 chars alphanum après le préfixe pays.
const tvaIntra = () => vine.string().regex(/^[A-Z]{2}[A-Z0-9]{2,18}$/u)
const phone = () => vine.string().maxLength(40) const phone = () => vine.string().maxLength(40)
const address = () => vine.string().maxLength(500) const address = () => vine.string().maxLength(500)
const addressLine = () => vine.string().maxLength(200)
const addressZip = () => vine.string().maxLength(20)
const addressCity = () => vine.string().maxLength(100)
const addressCountry = () => vine.string().regex(/^[A-Z]{2}$/u)
const notes = () => vine.string().maxLength(2000) const notes = () => vine.string().maxLength(2000)
// Prénom/nom du contact dédié — utilisés comme variables dans les templates // Prénom/nom du contact dédié — utilisés comme variables dans les templates
// custom ({{client.contactFirstName}}). Optionnels. // custom ({{client.contactFirstName}}). Optionnels.
@ -14,6 +22,10 @@ const contactName = () => vine.string().minLength(1).maxLength(80)
/** /**
* Validator pour POST /clients. Email **requis** : sans email, Rubis ne * Validator pour POST /clients. Email **requis** : sans email, Rubis ne
* peut pas relancer (pivot produit, cf. CLAUDE.md Principes). * peut pas relancer (pivot produit, cf. CLAUDE.md Principes).
*
* Adresse structurée (lines/zip/city/country) ajoutée avec l'éditeur de
* factures natif. `address` (string legacy) reste accepté pour compat
* le nouveau code lit en priorité les champs structurés.
*/ */
export const createClientValidator = vine.create({ export const createClientValidator = vine.create({
name: name(), name: name(),
@ -23,6 +35,13 @@ export const createClientValidator = vine.create({
phone: phone().nullable().optional(), phone: phone().nullable().optional(),
address: address().nullable().optional(), address: address().nullable().optional(),
siret: siret().nullable().optional(), siret: siret().nullable().optional(),
siren: siren().nullable().optional(),
tvaIntra: tvaIntra().nullable().optional(),
addressLine1: addressLine().nullable().optional(),
addressLine2: addressLine().nullable().optional(),
addressZip: addressZip().nullable().optional(),
addressCity: addressCity().nullable().optional(),
addressCountry: addressCountry().nullable().optional(),
notes: notes().nullable().optional(), notes: notes().nullable().optional(),
}) })
@ -37,5 +56,12 @@ export const updateClientValidator = vine.create({
phone: phone().nullable().optional(), phone: phone().nullable().optional(),
address: address().nullable().optional(), address: address().nullable().optional(),
siret: siret().nullable().optional(), siret: siret().nullable().optional(),
siren: siren().nullable().optional(),
tvaIntra: tvaIntra().nullable().optional(),
addressLine1: addressLine().nullable().optional(),
addressLine2: addressLine().nullable().optional(),
addressZip: addressZip().nullable().optional(),
addressCity: addressCity().nullable().optional(),
addressCountry: addressCountry().nullable().optional(),
notes: notes().nullable().optional(), notes: notes().nullable().optional(),
}) })

View File

@ -40,3 +40,56 @@ export const createInvoiceValidator = vine.create({
dueDate: 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(), planId: vine.string().uuid().nullable().optional(),
}) })
const HEX_RE = /^#[0-9a-fA-F]{6}$/u
/** Une ligne de l'éditeur de facture native. */
const invoiceLineObject = vine.object({
id: vine.string().minLength(1).maxLength(64),
description: vine.string().minLength(1).maxLength(500),
quantity: vine.number().positive(),
unitPriceCents: vine.number().min(0).max(100_000_000),
tvaRate: vine.number().in([0, 2.1, 5.5, 10, 20]),
})
/**
* POST /invoices/native création depuis l'éditeur natif.
*
* - pas de `numero` : alloué par le serveur (séquence strict)
* - pas de `amountTtcCents` : recalculé depuis lines
* - `lines` requis avec au moins 1 entrée
* - `themeSlug` + `accentColor` snapshotés sur la facture
* - `clientId` obligatoire (créer le client en amont si neuf)
* - `draft: true` ne consomme pas la séquence (brouillon)
*/
export const createNativeInvoiceValidator = vine.compile(
vine.object({
clientId: vine.string().uuid(),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
paymentTermsDays: vine.number().min(0).max(365),
planId: vine.string().uuid().nullable().optional(),
themeSlug: vine.enum(['classique', 'moderne', 'minimal', 'elegant'] as const),
accentColor: vine.string().regex(HEX_RE),
lines: vine.array(invoiceLineObject).minLength(1),
footerNotes: vine.string().maxLength(1000).nullable().optional(),
draft: vine.boolean().optional(),
})
)
/**
* POST /invoices/preview-pdf mêmes champs que la création, sans persister.
* Le serveur recalcule les totaux et stream le PDF.
*/
export const previewInvoiceValidator = vine.compile(
vine.object({
clientId: vine.string().uuid(),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
paymentTermsDays: vine.number().min(0).max(365),
themeSlug: vine.enum(['classique', 'moderne', 'minimal', 'elegant'] as const),
accentColor: vine.string().regex(HEX_RE),
lines: vine.array(invoiceLineObject).minLength(1),
footerNotes: vine.string().maxLength(1000).nullable().optional(),
})
)

View File

@ -0,0 +1,56 @@
import vine from '@vinejs/vine'
const HEX_RE = /^#[0-9a-fA-F]{6}$/u
const ISO_COUNTRY_RE = /^[A-Z]{2}$/u
const SIREN_RE = /^\d{9}$/u
const SIRET_RE = /^\d{14}$/u
const TVA_INTRA_RE = /^[A-Z]{2}[A-Z0-9]{2,18}$/u
const NAF_RE = /^\d{4}[A-Z]$/u
const IBAN_RE = /^[A-Z0-9 ]{15,40}$/u
const BIC_RE = /^[A-Z0-9]{8}([A-Z0-9]{3})?$/u
const issuerObject = vine.object({
companyName: vine.string().maxLength(200).nullable().optional(),
addressLine1: vine.string().maxLength(200).nullable().optional(),
addressLine2: vine.string().maxLength(200).nullable().optional(),
addressZip: vine.string().maxLength(20).nullable().optional(),
addressCity: vine.string().maxLength(100).nullable().optional(),
addressCountry: vine.string().regex(ISO_COUNTRY_RE).nullable().optional(),
siren: vine.string().regex(SIREN_RE).nullable().optional(),
siret: vine.string().regex(SIRET_RE).nullable().optional(),
tvaIntra: vine.string().regex(TVA_INTRA_RE).nullable().optional(),
rcs: vine.string().maxLength(120).nullable().optional(),
capital: vine.string().maxLength(120).nullable().optional(),
formeJuridique: vine.string().maxLength(40).nullable().optional(),
naf: vine.string().regex(NAF_RE).nullable().optional(),
contactEmail: vine.string().email().nullable().optional(),
contactPhone: vine.string().maxLength(40).nullable().optional(),
})
const ribObject = vine.object({
iban: vine.string().regex(IBAN_RE).nullable().optional(),
bic: vine.string().regex(BIC_RE).nullable().optional(),
bankName: vine.string().maxLength(120).nullable().optional(),
})
/**
* PATCH /organizations/me/invoice-settings partial. Une clé à `null`
* explicite reset au default sur ce champ ; une clé absente laisse intact.
*/
export const updateInvoiceSettingsValidator = vine.compile(
vine.object({
themeSlug: vine
.enum(['classique', 'moderne', 'minimal', 'elegant'] as const)
.optional(),
accentColor: vine.string().regex(HEX_RE).nullable().optional(),
numeroPrefix: vine.string().maxLength(40).nullable().optional(),
numeroNextSeq: vine.number().min(1).max(9_999_999).nullable().optional(),
numeroPadding: vine.number().min(1).max(10).nullable().optional(),
paymentTermsDays: vine.number().min(0).max(365).nullable().optional(),
penaltyRateText: vine.string().maxLength(1000).nullable().optional(),
escompteText: vine.string().maxLength(500).nullable().optional(),
footerLegalText: vine.string().maxLength(1000).nullable().optional(),
issuer: issuerObject.nullable().optional(),
rib: ribObject.nullable().optional(),
})
)

View File

@ -0,0 +1,81 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Éditeur de factures natif settings de facturation par organisation.
*
* `invoice_settings` est une colonne JSONB nullable qui stocke tout le
* paramétrage propre à l'émission de factures depuis Rubis (vs. l'upload
* OCR qui ne génère rien) :
*
* - identité émetteur figée dans le PDF (nom commercial, adresse, SIREN,
* SIRET, TVA intracom, RCS, capital, forme juridique, NAF)
* - RIB (IBAN + BIC + nom de banque) pour le pied de page paiement
* - choix du thème par défaut (slug parmi `INVOICE_THEMES`) et couleur
* d'accent (hex #RRGGBB)
* - numérotation strict séquentielle : préfixe + prochain compteur. On
* stocke `numero_next_seq` ici (et non sur `invoices`) parce que c'est
* un compteur d'org, pas une propriété de facture. La séquence elle-même
* est figée par `invoices.sequence_number` (cf. migration suivante).
* - mentions par défaut : conditions de paiement, pénalités de retard,
* escompte, mentions de pied de page. Snapshot dans chaque facture
* à l'émission pour que l'historique reste intact si l'org change ses
* mentions plus tard.
*
* On choisit JSONB pour la même raison que brand_settings : aucun champ
* n'a besoin d'être indexé/filtré individuellement, on lit toujours le
* settings entier au moment de générer un PDF.
*
* Schéma applicatif (cf. apps/api/app/services/invoice_settings.ts) :
* {
* themeSlug?: 'classique' | 'moderne' | 'minimal' | 'elegant',
* accentColor?: string, // hex #RRGGBB — défaut = brand.primaryColor
* numeroPrefix?: string, // ex "FAC-2026-"
* numeroNextSeq?: number, // ex 42 → prochain numéro "FAC-2026-0042"
* numeroPadding?: number, // 4 → "0042"
* paymentTermsDays?: number, // 30 par défaut
* penaltyRateText?: string, // "Taux annuel : trois fois le taux légal…"
* escompteText?: string, // "Pas d'escompte pour paiement anticipé."
* footerLegalText?: string, // texte libre additionnel
* issuer?: {
* companyName?: string,
* addressLine1?: string,
* addressLine2?: string,
* addressZip?: string,
* addressCity?: string,
* addressCountry?: string, // ISO 2 lettres, défaut "FR"
* siren?: string, // 9 chiffres
* siret?: string, // 14 chiffres
* tvaIntra?: string, // FR + 11 chiffres
* rcs?: string, // "RCS Paris 123 456 789"
* capital?: string, // "SARL au capital de 1 000 €"
* formeJuridique?: string, // "SARL", "SAS", "EI", etc.
* naf?: string, // code APE/NAF (5 chars)
* contactEmail?: string,
* contactPhone?: string,
* },
* rib?: {
* iban?: string,
* bic?: string,
* bankName?: string,
* },
* }
*
* Plan gating : pas de gating au niveau du settings toute org peut
* paramétrer sa facturation, le gating se fait sur la création de facture
* (cf. `canCreateInvoices`).
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.jsonb('invoice_settings').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('invoice_settings')
})
}
}

View File

@ -0,0 +1,55 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Enrichissement de `clients` pour l'éditeur de factures natif.
*
* Aujourd'hui un client a un `address` (string libre) et un `siret`. Pour
* émettre une vraie facture conforme art. 242 nonies A du CGI, on a besoin
* d'une adresse structurée (rue + CP + ville + pays), du SIREN explicite
* (les 9 premiers du SIRET, mais on le stocke distinct pour éviter d'avoir
* à le re-dériver à chaque rendu PDF), et du numéro de TVA intracommunautaire
* pour les factures B2B en exonération.
*
* Le champ `address` existant n'est PAS supprimé : il est conservé pour les
* factures OCR/saisie manuelle qui ne renseignent qu'une adresse libre. Le
* nouveau code (éditeur natif + relance emails) consomme prioritairement
* les champs structurés, et retombe sur `address` si vides cohérent
* avec la rétro-compatibilité des factures importées avant cette feature.
*
* Tous nullable : les clients existants ne sont pas migrés, l'éditeur natif
* exige juste les champs requis au moment de l'émission (validation côté
* controller, pas en DB).
*/
export default class extends BaseSchema {
protected tableName = 'clients'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// SIREN distinct du SIRET. Toujours 9 chars numériques mais on stocke en
// string pour préserver les zéros de tête.
table.string('siren', 9).nullable()
// FR + 11 chiffres pour la France, mais on prévoit large pour l'UE (jusqu'à 14).
table.string('tva_intra', 20).nullable()
// Adresse structurée. line1 = numéro + rue. line2 = complément (bât, étage).
table.string('address_line1', 200).nullable()
table.string('address_line2', 200).nullable()
table.string('address_zip', 20).nullable()
table.string('address_city', 100).nullable()
// ISO 3166-1 alpha-2. Pas de default 'FR' en DB — on laisse le default
// en applicatif pour pouvoir distinguer "non renseigné" de "FR explicite".
table.string('address_country', 2).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('siren')
table.dropColumn('tva_intra')
table.dropColumn('address_line1')
table.dropColumn('address_line2')
table.dropColumn('address_zip')
table.dropColumn('address_city')
table.dropColumn('address_country')
})
}
}

View File

@ -0,0 +1,98 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Enrichissement de `invoices` pour l'éditeur de factures natif.
*
* Les factures importées (OCR ou saisie manuelle) restent inchangées : tous
* les nouveaux champs sont nullable / default-false. Une facture pré-existante
* a simplement `is_native = false` et `lines = null` l'UI gère les deux
* cas (rendu OCR vs rendu native).
*
* Pour une facture native :
*
* - `lines` : JSONB avec les lignes de la facture. Schéma applicatif :
* [{ id, description, quantity, unitPriceCents, tvaRate, totalHtCents }]
* On garde id par ligne pour le diff côté UI (drag-and-drop ordering).
*
* - `client_snapshot` / `issuer_snapshot` : JSONB figés à l'émission. Une
* facture émise ne doit JAMAIS changer d'aspect rétroactivement si l'org
* modifie ses settings ou si le client change son adresse. C'est une
* exigence comptable (la facture en PDF est une preuve), et c'est aussi
* pourquoi `payment_terms_days`, `theme_slug`, `theme_accent_color`,
* `footer_notes` sont aussi dénormalisés ici.
*
* - `amount_ht_cents` / `amount_tva_cents` : recalcul possible depuis lines
* mais on stocke pour économiser la déserialisation à chaque listing/KPI.
* `amount_ttc_cents` existant = ht + tva, garanti par le controller à
* l'écriture.
*
* - `tva_breakdown` : ventilation par taux pour les mentions légales
* (obligatoire en France quand il y a plusieurs taux sur une même facture).
* Schéma : [{ rate: 20, htCents: 1000, tvaCents: 200 }].
*
* - `theme_slug` / `theme_accent_color` : snapshot du thème utilisé pour le
* rendu. Si on rebuild le PDF plus tard (regenerate-pdf), on retrouve
* exactement le même rendu.
*
* - `is_native` : distinction pour l'UI (icône, actions disponibles) une
* facture native peut être -éditée tant qu'elle n'est pas émise et peut
* re-générer son PDF ; une facture OCR ne peut pas.
*
* - `sequence_number` : entier de séquence strict pour la numérotation
* chronologique continue (art. 242 nonies A CGI). C'est l'index dans la
* séquence de l'org, et le numéro affiché (`numero`) est dérivé du préfixe
* + sequence_number padé (cf. invoice_settings). Stocké séparément pour
* pouvoir trouver le "next seq" en SQL natif (MAX) sans parser `numero`.
*
* - `pdf_generated_at` : timestamp de la dernière génération de PDF. Permet
* de savoir si le `pdf_storage_key` est à jour ou s'il faut regénérer
* (utile en cas de migration future de templates).
*/
export default class extends BaseSchema {
protected tableName = 'invoices'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.jsonb('lines').nullable()
table.jsonb('client_snapshot').nullable()
table.jsonb('issuer_snapshot').nullable()
table.integer('amount_ht_cents').nullable()
table.integer('amount_tva_cents').nullable()
table.jsonb('tva_breakdown').nullable()
table.integer('payment_terms_days').nullable()
table.text('footer_notes').nullable()
table.string('theme_slug', 50).nullable()
table.string('theme_accent_color', 7).nullable()
table.boolean('is_native').notNullable().defaultTo(false)
table.integer('sequence_number').nullable()
table.timestamp('pdf_generated_at').nullable()
// Index sur (org, sequence_number) pour trouver le max courant en O(log n)
// au moment d'allouer le prochain numéro séquentiel.
table.index(['organization_id', 'sequence_number'])
// Unicité : pas deux factures d'une même org sur le même n° de séquence.
// partial unique (nullable column) — Postgres autorise plusieurs NULL.
table.unique(['organization_id', 'sequence_number'])
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropUnique(['organization_id', 'sequence_number'])
table.dropIndex(['organization_id', 'sequence_number'])
table.dropColumn('lines')
table.dropColumn('client_snapshot')
table.dropColumn('issuer_snapshot')
table.dropColumn('amount_ht_cents')
table.dropColumn('amount_tva_cents')
table.dropColumn('tva_breakdown')
table.dropColumn('payment_terms_days')
table.dropColumn('footer_notes')
table.dropColumn('theme_slug')
table.dropColumn('theme_accent_color')
table.dropColumn('is_native')
table.dropColumn('sequence_number')
table.dropColumn('pdf_generated_at')
})
}
}

View File

@ -22,6 +22,7 @@
"#exceptions/*": "./app/exceptions/*.js", "#exceptions/*": "./app/exceptions/*.js",
"#models/*": "./app/models/*.js", "#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js", "#mails/*": "./app/mails/*.js",
"#pdf-templates/*": "./app/pdf-templates/*.js",
"#services/*": "./app/services/*.js", "#services/*": "./app/services/*.js",
"#jobs/*": "./app/jobs/*.js", "#jobs/*": "./app/jobs/*.js",
"#listeners/*": "./app/listeners/*.js", "#listeners/*": "./app/listeners/*.js",

View File

@ -23,6 +23,8 @@ const BlogUploadsController = () => import('#controllers/blog_uploads_controller
const BrandController = () => import('#controllers/brand_controller') const BrandController = () => import('#controllers/brand_controller')
const BankingController = () => import('#controllers/banking_controller') const BankingController = () => import('#controllers/banking_controller')
const WebhooksPowensController = () => import('#controllers/webhooks_powens_controller') const WebhooksPowensController = () => import('#controllers/webhooks_powens_controller')
const InvoiceSettingsController = () => import('#controllers/invoice_settings_controller')
const InvoiceThemesController = () => import('#controllers/invoice_themes_controller')
router router
@ -205,11 +207,40 @@ router
.group(() => { .group(() => {
router.get('me', [controllers.Organizations, 'show']).as('show') router.get('me', [controllers.Organizations, 'show']).as('show')
router.patch('me', [controllers.Organizations, 'update']).as('update') router.patch('me', [controllers.Organizations, 'update']).as('update')
/**
* Settings de facturation native (éditeur de factures). Aucun gating
* de plan : toute org peut paramétrer sa facturation. Le gating
* porte sur la création (`canCreateInvoices`).
*
* - GET /me/invoice-settings settings + valeurs résolues
* - PATCH /me/invoice-settings maj partielle (null = reset)
*/
router
.get('me/invoice-settings', [InvoiceSettingsController, 'show'])
.as('invoice-settings.show')
router
.patch('me/invoice-settings', [InvoiceSettingsController, 'update'])
.as('invoice-settings.update')
}) })
.prefix('organizations') .prefix('organizations')
.as('organizations') .as('organizations')
.use(middleware.auth()) .use(middleware.auth())
/**
* Thèmes de facture disponibles auth requise. Retourne la liste des
* 4 templates pré-faits avec leurs métadonnées (slug + name + description)
* pour peupler la galerie de sélection dans l'éditeur et /parametres/facturation.
* Le rendu lui-même vit côté SPA (packages/ui/invoice-templates).
*/
router
.group(() => {
router.get('', [InvoiceThemesController, 'index']).as('index')
})
.prefix('invoice-themes')
.as('invoice-themes')
.use(middleware.auth())
/** /**
* Marque blanche auth + plan Business obligatoires. Le middleware * Marque blanche auth + plan Business obligatoires. Le middleware
* `assertBusinessPlan` throw 403 `business_plan_required` que le SPA * `assertBusinessPlan` throw 403 `business_plan_required` que le SPA
@ -334,20 +365,36 @@ router
.use(middleware.auth()) .use(middleware.auth())
/** /**
* Demo auth requise. Mode démo opt-in par org (cf. CLAUDE.md * Demo réservé aux admins Rubis (outil de prospection commerciale).
* Architecture). Routes opérantes seulement si `org.demo_mode = true`. * Gating fin :
* - GET /state : auth seul, parce que `DemoClock` dans `AppLayout`
* fetch sur chaque page pour tous les users. Un non-admin reçoit
* `{ active: false }` (son org n'est jamais en mode démo) pas
* de leak, juste un payload neutre.
* - GET /inbox + POST /start, /end, /tick : auth + admin. Mutations
* et lecture des emails capturés (potentiellement sensibles).
*
* UI : la section "Démonstration" dans /parametres est aussi gated
* sur `user.isAdmin` (cf. parametres.tsx).
*/ */
router
.group(() => {
router.get('state', [controllers.Demo, 'state']).as('state')
})
.prefix('demo')
.as('demo')
.use(middleware.auth())
router router
.group(() => { .group(() => {
router.post('start', [controllers.Demo, 'start']).as('start') router.post('start', [controllers.Demo, 'start']).as('start')
router.post('end', [controllers.Demo, 'end']).as('end') router.post('end', [controllers.Demo, 'end']).as('end')
router.post('tick', [controllers.Demo, 'tick']).as('tick') router.post('tick', [controllers.Demo, 'tick']).as('tick')
router.get('state', [controllers.Demo, 'state']).as('state')
router.get('inbox', [controllers.Demo, 'inbox']).as('inbox') router.get('inbox', [controllers.Demo, 'inbox']).as('inbox')
}) })
.prefix('demo') .prefix('demo')
.as('demo') .as('demo.admin')
.use(middleware.auth()) .use([middleware.auth(), middleware.admin()])
/** /**
* Dashboard auth requise. Calculs agrégés on-the-fly (pas de cache V1). * Dashboard auth requise. Calculs agrégés on-the-fly (pas de cache V1).
@ -376,6 +423,16 @@ router
router.post('', [controllers.Invoices, 'store']).as('store') router.post('', [controllers.Invoices, 'store']).as('store')
router.get('counts', [controllers.Invoices, 'counts']).as('counts') router.get('counts', [controllers.Invoices, 'counts']).as('counts')
/**
* Éditeur de factures natif :
* - POST /invoices/native création depuis l'éditeur (séquence strict)
* - POST /invoices/preview-pdf preview PDF sans persister
*
* Déclarés AVANT /:id pour ne pas être mangés par le matcher uuid.
*/
router.post('native', [controllers.Invoices, 'storeNative']).as('storeNative')
router.post('preview-pdf', [controllers.Invoices, 'previewPdf']).as('previewPdf')
// OCR / Import batch (cf. ImportBatchesController) // OCR / Import batch (cf. ImportBatchesController)
router.post('upload', [controllers.ImportBatches, 'upload']).as('upload') router.post('upload', [controllers.ImportBatches, 'upload']).as('upload')
router router

View File

@ -81,16 +81,20 @@ export function BankingSection({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [callbackStatus, callbackReason]); }, [callbackStatus, callbackReason]);
if (!isPaid) { // Order matters : `comingSoon` doit gagner sur `isPaid`. La feature n'est
return <UpsellCard />; // pas dispo pour personne pendant la fenêtre KYC Powens, donc inciter un
} // Free à passer Pro pour "connecter sa banque" serait trompeur (il
// tomberait sur "Bientôt disponible" après l'upgrade). On annonce la
// Banking pas encore activé mais teaser ON → afficher "Bientôt disponible" // feature à venir à tout le monde ; l'upsell reviendra automatiquement
// pour annoncer la feature aux Pro/Business pendant la fenêtre KYC Powens. // une fois `BANKING_ENABLED=true` en prod.
if (status?.comingSoon) { if (status?.comingSoon) {
return <ComingSoonCard />; return <ComingSoonCard />;
} }
if (!isPaid) {
return <UpsellCard />;
}
return ( return (
<BankingPaidView <BankingPaidView
isLoading={connectionsQuery.isLoading} isLoading={connectionsQuery.isLoading}

View File

@ -231,4 +231,45 @@ export const api = {
const blob = await res.blob(); const blob = await res.blob();
return { blob, contentType: res.headers.get("content-type") ?? blob.type }; return { blob, contentType: res.headers.get("content-type") ?? blob.type };
}, },
/**
* POST avec body JSON et réponse binaire. Pendant inverse de `fetchBlob`,
* utilisé pour la preview PDF (POST /invoices/preview-pdf renvoie un PDF).
*
* Mêmes contraintes que fetchBlob : pas de silent refresh sur 401 (le
* caller debounced retry de toute façon à chaque keystroke). Le body est
* JSON-encoded ; pas de support FormData ici (cas spécifique aux preview).
*/
postBlob: async (
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<Blob> => {
const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/pdf,application/octet-stream",
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
credentials: "include",
body: JSON.stringify(body),
signal,
});
if (!res.ok) {
// Le serveur peut renvoyer du JSON `{errors: [...]}` même sur un POST
// qui attendait un Blob — on tente de parser pour donner un message utile.
const text = await res.text().catch(() => null);
let message = `HTTP ${res.status} on ${path}`;
try {
const json = text ? JSON.parse(text) : null;
if (json?.errors?.[0]?.message) message = json.errors[0].message;
} catch {
// text non-JSON → on garde le message générique
}
throw new ApiError(res.status, "blob_post_failed", message);
}
return res.blob();
},
}; };

View File

@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { InvoiceSettings, ResolvedInvoiceSettings, InvoiceTheme } from "@rubis/shared";
import { api } from "@/lib/api";
/**
* Réponse du GET /organizations/me/invoice-settings :
* - `settings` : raw JSONB (potentiellement {} pour une org neuve)
* - `resolved` : settings résolus avec defaults (consommables par l'éditeur
* et utilisés côté preview pour rendre les mêmes valeurs que le PDF final)
*/
export type InvoiceSettingsState = {
settings: InvoiceSettings;
resolved: ResolvedInvoiceSettings;
};
const invoiceSettingsKey = ["invoice-settings"] as const;
const invoiceThemesKey = ["invoice-themes"] as const;
export function useInvoiceSettings() {
return useQuery({
queryKey: invoiceSettingsKey,
queryFn: () =>
api.get<InvoiceSettingsState>(
"/api/v1/organizations/me/invoice-settings",
),
staleTime: 30_000,
});
}
/**
* PATCH partiel. Sémantique cohérente avec brand_settings :
* - clé à `null` explicite = reset au default sur ce champ précis
* - clé absente = laisse intact
*
* Les objets imbriqués (`issuer`, `rib`) suivent la même règle en deep merge.
*/
export function useUpdateInvoiceSettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: Partial<InvoiceSettings>) =>
api.patch<InvoiceSettingsState>(
"/api/v1/organizations/me/invoice-settings",
patch,
),
onSuccess: (next) => {
qc.setQueryData(invoiceSettingsKey, next);
},
});
}
/** GET /invoice-themes — liste statique des 4 thèmes. */
export function useInvoiceThemes() {
return useQuery({
queryKey: invoiceThemesKey,
queryFn: () => api.get<InvoiceTheme[]>("/api/v1/invoice-themes"),
// Les thèmes sont statiques côté serveur — pas la peine de re-fetch.
staleTime: Infinity,
});
}

View File

@ -1,3 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { CreateNativeInvoiceInput, Invoice, PreviewInvoiceInput } from "@rubis/shared";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
export type ImportBatchResponse = { export type ImportBatchResponse = {
@ -12,3 +15,38 @@ export function uploadInvoiceFiles(files: File[]): Promise<ImportBatchResponse>
} }
return api.post<ImportBatchResponse>("/api/v1/invoices/upload", formData); return api.post<ImportBatchResponse>("/api/v1/invoices/upload", formData);
} }
/**
* POST /invoices/native création depuis l'éditeur natif.
*
* - Pas de `numero` : alloué côté serveur (séquence strict)
* - Pas de `amountTtcCents` : recalculé depuis lines
* - `draft: true` la facture est créée mais ne consomme pas la séquence
* (numero éphémère "BROUILLON-XXX")
*/
export function useCreateNativeInvoice() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateNativeInvoiceInput) =>
api.post<Invoice>("/api/v1/invoices/native", input),
onSuccess: () => {
// Toute la liste / counts deviennent obsolètes.
qc.invalidateQueries({ queryKey: ["invoices"] });
qc.invalidateQueries({ queryKey: ["invoice-counts"] });
},
});
}
/**
* POST /invoices/preview-pdf stream un PDF sans persister.
*
* Retourne un Blob à transformer en objectURL côté composant pour l'afficher
* dans un <iframe>. À débouncer côté caller (500ms typique) pour ne pas
* spammer le serveur pendant la saisie.
*/
export async function previewInvoicePdf(
input: PreviewInvoiceInput,
signal?: AbortSignal,
): Promise<Blob> {
return api.postBlob("/api/v1/invoices/preview-pdf", input, signal);
}

View File

@ -240,11 +240,46 @@ export const mockDb = {
}, },
createClient( createClient(
orgId: string, orgId: string,
input: Omit<Client, "id" | "organizationId" | "createdAt" | "updatedAt">, // Les champs "invoicing" (SIREN, TVA intra, adresse structurée) sont
// optionnels côté mock : tous les call sites OCR/legacy les omettent.
// Defaults à null pour rester aligné avec le shape applicatif.
input: Omit<
Client,
| "id"
| "organizationId"
| "createdAt"
| "updatedAt"
| "siren"
| "tvaIntra"
| "addressLine1"
| "addressLine2"
| "addressZip"
| "addressCity"
| "addressCountry"
> &
Partial<
Pick<
Client,
| "siren"
| "tvaIntra"
| "addressLine1"
| "addressLine2"
| "addressZip"
| "addressCity"
| "addressCountry"
>
>,
): Client { ): Client {
const db = load(); const db = load();
const now = new Date().toISOString(); const now = new Date().toISOString();
const client: Client = { const client: Client = {
siren: null,
tvaIntra: null,
addressLine1: null,
addressLine2: null,
addressZip: null,
addressCity: null,
addressCountry: null,
...input, ...input,
id: `cli_${crypto.randomUUID()}`, id: `cli_${crypto.randomUUID()}`,
organizationId: orgId, organizationId: orgId,
@ -332,10 +367,66 @@ export const mockDb = {
listInvoicesForOrg(orgId: string): StoredInvoice[] { listInvoicesForOrg(orgId: string): StoredInvoice[] {
return load().invoices.filter((i) => i.organizationId === orgId); return load().invoices.filter((i) => i.organizationId === orgId);
}, },
createInvoice(orgId: string, input: Omit<StoredInvoice, "id" | "organizationId" | "createdAt" | "updatedAt">): StoredInvoice { createInvoice(
orgId: string,
// Tous les champs "native editor" sont optionnels côté mock : les call
// sites OCR/saisie manuelle ne les renseignent pas. Defaults à null
// (factures importées, pas éditées dans Rubis).
input: Omit<
StoredInvoice,
| "id"
| "organizationId"
| "createdAt"
| "updatedAt"
| "sequenceNumber"
| "amountHtCents"
| "amountTvaCents"
| "tvaBreakdown"
| "lines"
| "paymentTermsDays"
| "clientSnapshot"
| "issuerSnapshot"
| "themeSlug"
| "themeAccentColor"
| "footerNotes"
| "isNative"
| "pdfGeneratedAt"
> &
Partial<
Pick<
StoredInvoice,
| "sequenceNumber"
| "amountHtCents"
| "amountTvaCents"
| "tvaBreakdown"
| "lines"
| "paymentTermsDays"
| "clientSnapshot"
| "issuerSnapshot"
| "themeSlug"
| "themeAccentColor"
| "footerNotes"
| "isNative"
| "pdfGeneratedAt"
>
>,
): StoredInvoice {
const db = load(); const db = load();
const now = new Date().toISOString(); const now = new Date().toISOString();
const invoice: StoredInvoice = { const invoice: StoredInvoice = {
sequenceNumber: null,
amountHtCents: null,
amountTvaCents: null,
tvaBreakdown: null,
lines: null,
paymentTermsDays: null,
clientSnapshot: null,
issuerSnapshot: null,
themeSlug: null,
themeAccentColor: null,
footerNotes: null,
isNative: false,
pdfGeneratedAt: null,
...input, ...input,
id: `inv_${crypto.randomUUID()}`, id: `inv_${crypto.randomUUID()}`,
organizationId: orgId, organizationId: orgId,

View File

@ -102,6 +102,28 @@ const createClientSchema = z.object({
.nullable() .nullable()
.optional() .optional()
.default(null), .default(null),
siren: z
.string()
.regex(/^\d{9}$/u, "Le SIREN doit contenir 9 chiffres")
.nullable()
.optional()
.default(null),
tvaIntra: z
.string()
.regex(/^[A-Z]{2}[A-Z0-9]{2,18}$/u, "Format TVA intracom invalide")
.nullable()
.optional()
.default(null),
addressLine1: z.string().max(200).nullable().optional().default(null),
addressLine2: z.string().max(200).nullable().optional().default(null),
addressZip: z.string().max(20).nullable().optional().default(null),
addressCity: z.string().max(100).nullable().optional().default(null),
addressCountry: z
.string()
.regex(/^[A-Z]{2}$/u, "Code pays ISO 2 lettres")
.nullable()
.optional()
.default(null),
notes: z.string().max(2000).nullable().optional().default(null), notes: z.string().max(2000).nullable().optional().default(null),
}); });
@ -201,6 +223,13 @@ export const clientHandlers = [
phone: parsed.data.phone, phone: parsed.data.phone,
address: parsed.data.address, address: parsed.data.address,
siret: parsed.data.siret, siret: parsed.data.siret,
siren: parsed.data.siren ?? null,
tvaIntra: parsed.data.tvaIntra ?? null,
addressLine1: parsed.data.addressLine1 ?? null,
addressLine2: parsed.data.addressLine2 ?? null,
addressZip: parsed.data.addressZip ?? null,
addressCity: parsed.data.addressCity ?? null,
addressCountry: parsed.data.addressCountry ?? null,
notes: parsed.data.notes, notes: parsed.data.notes,
}); });
return HttpResponse.json({ data: created }, { status: 201 }); return HttpResponse.json({ data: created }, { status: 201 });

View File

@ -18,7 +18,40 @@ function isoFromOffset(daysOffset: number, hour = 9): string {
const ORG = "org_demo"; const ORG = "org_demo";
export const SEED_CLIENTS: Client[] = [ /** Defaults pour les champs de `Client` ajoutés par l'éditeur de factures natif.
* Les seeds existants ne renseignent que `address` (legacy) l'adresse
* structurée et le SIREN/TVA intra restent null pour cohérence avec
* une org qui vient d'arriver sur Rubis. */
const CLIENT_INVOICING_DEFAULTS = {
siren: null,
tvaIntra: null,
addressLine1: null,
addressLine2: null,
addressZip: null,
addressCity: null,
addressCountry: null,
} as const;
/** Defaults pour les champs de `Invoice` ajoutés par l'éditeur de factures
* natif. Les seeds simulent des factures importées (OCR/manuel) avant la
* feature : pas de lignes, pas de thème, pas de séquence. */
const INVOICE_NATIVE_DEFAULTS = {
sequenceNumber: null,
amountHtCents: null,
amountTvaCents: null,
tvaBreakdown: null,
lines: null,
paymentTermsDays: null,
clientSnapshot: null,
issuerSnapshot: null,
themeSlug: null,
themeAccentColor: null,
footerNotes: null,
isNative: false,
pdfGeneratedAt: null,
} as const;
export const SEED_CLIENTS: Client[] = ([
{ {
id: "cli_martin", id: "cli_martin",
organizationId: ORG, organizationId: ORG,
@ -89,7 +122,10 @@ export const SEED_CLIENTS: Client[] = [
createdAt: isoFromOffset(-30), createdAt: isoFromOffset(-30),
updatedAt: isoFromOffset(-1), updatedAt: isoFromOffset(-1),
}, },
]; ] satisfies Omit<Client, keyof typeof CLIENT_INVOICING_DEFAULTS>[]).map((c) => ({
...CLIENT_INVOICING_DEFAULTS,
...c,
}));
export const SEED_PLANS: Plan[] = [ export const SEED_PLANS: Plan[] = [
{ {
@ -255,7 +291,7 @@ export const SEED_PLANS: Plan[] = [
type SeedInvoice = Invoice & { clientName: string; planName: string | null; statusLabel?: string }; type SeedInvoice = Invoice & { clientName: string; planName: string | null; statusLabel?: string };
export const SEED_INVOICES: SeedInvoice[] = [ export const SEED_INVOICES: SeedInvoice[] = ([
// À relancer (échéance future) // À relancer (échéance future)
{ {
id: "inv_001", id: "inv_001",
@ -445,4 +481,7 @@ export const SEED_INVOICES: SeedInvoice[] = [
createdAt: isoFromOffset(-100), createdAt: isoFromOffset(-100),
updatedAt: isoFromOffset(-15), updatedAt: isoFromOffset(-15),
}, },
]; ] satisfies Omit<SeedInvoice, keyof typeof INVOICE_NATIVE_DEFAULTS>[]).map((i) => ({
...INVOICE_NATIVE_DEFAULTS,
...i,
}));

View File

@ -188,13 +188,21 @@ function FacturesPage() {
pour voir la timeline. pour voir la timeline.
</p> </p>
</div> </div>
<Button size="sm" asChild className="shrink-0"> <div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="secondary" asChild>
<Link to="/factures/import"> <Link to="/factures/import">
<Plus size={14} aria-hidden="true" /> <span className="hidden sm:inline">Importer</span>
<span className="hidden sm:inline">Nouvelle facture</span> <span className="sm:hidden">Import</span>
<span className="sm:hidden">Nouvelle</span>
</Link> </Link>
</Button> </Button>
<Button size="sm" asChild>
<Link to="/factures/nouvelle">
<Plus size={14} aria-hidden="true" />
<span className="hidden sm:inline">Créer une facture</span>
<span className="sm:hidden">Créer</span>
</Link>
</Button>
</div>
</div> </div>
<PlanLimitBanner /> <PlanLimitBanner />

View File

@ -0,0 +1,599 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { addDays } from "date-fns";
import {
ArrowLeft,
Check,
GripVertical,
Loader2,
Plus,
Save,
Send,
Trash2,
} from "lucide-react";
import type {
CreateNativeInvoiceInput,
InvoiceLineInput,
InvoiceThemeSlug,
Plan,
} from "@rubis/shared";
import { FRENCH_TVA_RATES } from "@rubis/shared";
import { api } from "@/lib/api";
import { useInvoiceSettings, useInvoiceThemes } from "@/lib/invoice-settings";
import { previewInvoicePdf, useCreateNativeInvoice } from "@/lib/invoices";
import { ApiError } from "@/lib/api";
import { Button, Card, Eyebrow } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
import { Field } from "@/components/ui/Field";
import { ClientCombobox } from "@/components/factures/ClientCombobox";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/_app/factures_/nouvelle")({
component: FacturesNouvellePage,
});
const newLine = (): InvoiceLineInput => ({
id: crypto.randomUUID(),
description: "",
quantity: 1,
unitPriceCents: 0,
tvaRate: 20,
});
function FacturesNouvellePage() {
const navigate = useNavigate();
const { data: settings } = useInvoiceSettings();
const { data: themes } = useInvoiceThemes();
const { data: plans } = useQuery({
queryKey: ["plans"],
queryFn: () => api.get<Plan[]>("/api/v1/plans"),
});
// ============== Form state ==============
const [clientName, setClientName] = useState("");
const [clientId, setClientId] = useState<string | null>(null);
const todayIso = useMemo(() => new Date().toISOString().slice(0, 10), []);
const [issueDate, setIssueDate] = useState(todayIso);
const [paymentTermsDays, setPaymentTermsDays] = useState(30);
const [planId, setPlanId] = useState<string | null>(null);
const [themeSlug, setThemeSlug] = useState<InvoiceThemeSlug>("classique");
const [accentColor, setAccentColor] = useState("#9F1239");
const [lines, setLines] = useState<InvoiceLineInput[]>([newLine()]);
const [footerNotes, setFooterNotes] = useState("");
// Initialize defaults from invoice-settings (one-shot, après load).
const settingsInitDone = useRef(false);
useEffect(() => {
if (settings?.resolved && !settingsInitDone.current) {
setThemeSlug(settings.resolved.themeSlug);
setAccentColor(settings.resolved.accentColor);
setPaymentTermsDays(settings.resolved.paymentTermsDays);
settingsInitDone.current = true;
}
}, [settings]);
const dueDateIso = useMemo(
() => addDays(new Date(issueDate), paymentTermsDays).toISOString().slice(0, 10),
[issueDate, paymentTermsDays],
);
// ============== Totals (côté client pour feedback instantané) ==============
const totals = useMemo(() => computeTotals(lines), [lines]);
// ============== Preview PDF debounced ==============
const { previewUrl, previewError, isRefreshing } = usePreview({
clientId,
issueDate,
paymentTermsDays,
themeSlug,
accentColor,
lines,
footerNotes,
});
// ============== Submit ==============
const create = useCreateNativeInvoice();
const [submitError, setSubmitError] = useState<string | null>(null);
const onSubmit = async (draft: boolean) => {
if (!clientId) {
setSubmitError("Sélectionnez un client.");
return;
}
setSubmitError(null);
const payload: CreateNativeInvoiceInput = {
clientId,
issueDate: new Date(issueDate).toISOString(),
dueDate: new Date(dueDateIso).toISOString(),
paymentTermsDays,
planId: planId ?? undefined,
themeSlug,
accentColor,
lines,
footerNotes: footerNotes.trim() === "" ? null : footerNotes,
draft,
};
try {
const invoice = await create.mutateAsync(payload);
navigate({ to: "/factures/$id", params: { id: invoice.id } });
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Erreur inconnue");
}
};
const canSubmit =
!!clientId &&
lines.length > 0 &&
lines.every((l) => l.description.trim() !== "" && l.unitPriceCents >= 0);
return (
<div className="flex flex-col gap-4">
<header>
<Button size="sm" variant="ghost" asChild className="mb-3 -ml-2">
<Link to="/factures">
<ArrowLeft size={14} aria-hidden="true" /> Retour aux factures
</Link>
</Button>
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Nouvelle facture
</h1>
<p className="mt-1 text-[13.5px] text-ink-3">
Composez votre facture, l'aperçu se rafraîchit automatiquement.
L'émission alloue le prochain numéro de la séquence ; le brouillon
conserve un numéro éphémère.
</p>
</header>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* ========== Édition ========== */}
<div className="flex flex-col gap-4">
<Card padding="md" className="flex flex-col gap-4">
<Eyebrow>Destinataire</Eyebrow>
<Field label="Client" hint="Recherchez ou créez à la volée">
<ClientCombobox
value={clientName}
selectedClientId={clientId}
onChange={({ value, clientId }) => {
setClientName(value);
setClientId(clientId);
}}
/>
</Field>
</Card>
<Card padding="md" className="flex flex-col gap-4">
<Eyebrow>Dates & paiement</Eyebrow>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Émise le">
<Input
type="date"
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
/>
</Field>
<Field label="Délai" hint="Jours">
<Input
type="number"
value={paymentTermsDays}
onChange={(e) =>
setPaymentTermsDays(Number(e.target.value) || 0)
}
min={0}
max={365}
/>
</Field>
<Field label="Échéance" hint="Calculée automatiquement">
<Input type="date" value={dueDateIso} readOnly className="bg-cream-2" />
</Field>
</div>
<Field
label="Plan de relance"
hint="Optionnel — si associé, Rubis programmera le check-in puis les relances à l'échéance."
>
<select
value={planId ?? ""}
onChange={(e) => setPlanId(e.target.value || null)}
className="block w-full rounded-default border border-line bg-white px-3.5 py-3 text-base lg:text-[15px] text-ink"
>
<option value="">Aucun plan (relances manuelles)</option>
{(plans ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
</Card>
<Card padding="md" className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Eyebrow>Lignes</Eyebrow>
<Button
size="sm"
variant="ghost"
onClick={() => setLines((ls) => [...ls, newLine()])}
>
<Plus size={14} aria-hidden="true" /> Ajouter une ligne
</Button>
</div>
<LinesEditor lines={lines} setLines={setLines} />
<TotalsBlock totals={totals} />
</Card>
<Card padding="md" className="flex flex-col gap-4">
<Eyebrow>Thème & accent</Eyebrow>
<div className="grid gap-3 sm:grid-cols-4">
{(themes ?? []).map((t) => (
<button
key={t.slug}
type="button"
onClick={() => setThemeSlug(t.slug)}
className={cn(
"rounded-default border bg-white px-3 py-2 text-left text-[13px] font-semibold transition-colors",
themeSlug === t.slug
? "border-rubis ring-4 ring-rubis-glow text-ink"
: "border-line text-ink-2 hover:border-rubis-light",
)}
>
<span className="flex items-center justify-between">
{t.name}
{themeSlug === t.slug ? (
<Check size={14} className="text-rubis" aria-hidden="true" />
) : null}
</span>
</button>
))}
</div>
<Field label="Couleur d'accent" hint="Hex #RRGGBB">
<div className="flex items-center gap-3">
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="h-11 w-16 cursor-pointer rounded-default border border-line bg-white p-1"
aria-label="Choisir la couleur d'accent"
/>
<Input
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
maxLength={7}
className="font-mono lg:max-w-[160px]"
/>
</div>
</Field>
</Card>
<Card padding="md">
<Eyebrow>Notes</Eyebrow>
<Textarea
value={footerNotes}
onChange={(e) => setFooterNotes(e.target.value)}
rows={3}
maxLength={1000}
placeholder="Notes affichées en pied de facture (références projet, conditions spécifiques…)"
className="mt-2"
/>
</Card>
</div>
{/* ========== Preview ========== */}
<div className="lg:sticky lg:top-4 lg:self-start">
<Card padding="sm" className="flex h-[calc(100vh-160px)] flex-col gap-2">
<div className="flex items-center justify-between px-2">
<Eyebrow>Aperçu</Eyebrow>
{isRefreshing ? (
<span className="flex items-center gap-1 text-[11px] text-ink-3">
<Loader2 size={11} className="animate-spin" /> Génération
</span>
) : null}
</div>
{previewError ? (
<div className="flex flex-1 items-center justify-center rounded-default border border-dashed border-line bg-cream-2 p-6 text-center text-[13px] text-ink-3">
{previewError}
</div>
) : previewUrl ? (
<iframe
src={previewUrl}
title="Aperçu de la facture"
className="flex-1 w-full rounded-default border border-line bg-white"
/>
) : (
<div className="flex flex-1 items-center justify-center rounded-default border border-dashed border-line bg-cream-2 p-6 text-center text-[13px] text-ink-3">
Sélectionnez un client et au moins une ligne pour générer l'aperçu.
</div>
)}
</Card>
</div>
</div>
{/* ========== Footer actions ========== */}
<div className="sticky bottom-0 -mx-4 mt-2 border-t border-line bg-cream/95 px-4 py-3 backdrop-blur lg:-mx-6 lg:px-6">
<div className="flex items-center justify-end gap-3">
{submitError ? (
<p className="mr-auto text-[13px] font-medium text-rubis-deep">
{submitError}
</p>
) : null}
<Button
variant="secondary"
size="md"
onClick={() => onSubmit(true)}
disabled={!canSubmit || create.isPending}
>
<Save size={14} aria-hidden="true" /> Enregistrer en brouillon
</Button>
<Button
size="md"
onClick={() => onSubmit(false)}
disabled={!canSubmit || create.isPending}
>
<Send size={14} aria-hidden="true" /> Émettre la facture
</Button>
</div>
<p className="mt-2 text-right text-[11.5px] text-ink-3">
Émettre = alloue le prochain numéro de la séquence (irréversible).
Brouillon = conserve un numéro éphémère, modifiable plus tard.
</p>
</div>
</div>
);
}
// ============================================================================
// LinesEditor — table éditable avec add/remove (drag-and-drop = V2)
// ============================================================================
function LinesEditor({
lines,
setLines,
}: {
lines: InvoiceLineInput[];
setLines: (next: InvoiceLineInput[] | ((prev: InvoiceLineInput[]) => InvoiceLineInput[])) => void;
}) {
const update = (id: string, patch: Partial<InvoiceLineInput>) => {
setLines((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
};
const remove = (id: string) => {
setLines((ls) => ls.filter((l) => l.id !== id));
};
return (
<div className="flex flex-col gap-2">
{/* Header */}
<div className="hidden grid-cols-[24px_1fr_80px_120px_80px_120px_28px] gap-2 px-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3 sm:grid">
<span aria-hidden="true" />
<span>Désignation</span>
<span className="text-right">Qté</span>
<span className="text-right">P.U. HT</span>
<span className="text-right">TVA</span>
<span className="text-right">Total HT</span>
<span aria-hidden="true" />
</div>
{lines.map((line) => {
const totalHt = Math.round(line.quantity * line.unitPriceCents);
return (
<div
key={line.id}
className="grid grid-cols-1 gap-2 rounded-default border border-line bg-white p-2 sm:grid-cols-[24px_1fr_80px_120px_80px_120px_28px] sm:items-center sm:p-1.5"
>
<span className="hidden text-ink-3 sm:flex sm:justify-center" aria-hidden="true">
<GripVertical size={14} />
</span>
<Input
value={line.description}
onChange={(e) => update(line.id, { description: e.target.value })}
placeholder="Désignation"
className="text-[14px]"
/>
<Input
type="number"
value={line.quantity}
onChange={(e) => update(line.id, { quantity: Number(e.target.value) || 0 })}
step="0.5"
min={0}
className="text-right text-[14px]"
/>
<Input
type="number"
value={(line.unitPriceCents / 100).toFixed(2)}
onChange={(e) =>
update(line.id, {
unitPriceCents: Math.round((Number(e.target.value) || 0) * 100),
})
}
step="0.01"
min={0}
className="text-right text-[14px]"
placeholder="0,00"
/>
<select
value={line.tvaRate}
onChange={(e) => update(line.id, { tvaRate: Number(e.target.value) })}
className="rounded-default border border-line bg-white px-2 py-2 text-[14px] text-ink"
>
{FRENCH_TVA_RATES.map((rate) => (
<option key={rate} value={rate}>
{Number.isInteger(rate) ? `${rate} %` : `${rate.toString().replace(".", ",")} %`}
</option>
))}
</select>
<div className="text-right font-mono text-[14px] tabular-nums text-ink">
{(totalHt / 100).toLocaleString("fr-FR", {
style: "currency",
currency: "EUR",
})}
</div>
<button
type="button"
onClick={() => remove(line.id)}
disabled={lines.length === 1}
className="text-ink-3 hover:text-rubis-deep disabled:opacity-30"
aria-label="Supprimer cette ligne"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
);
}
// ============================================================================
// TotalsBlock — récapitulatif HT / TVA / TTC
// ============================================================================
function TotalsBlock({
totals,
}: {
totals: {
amountHtCents: number;
amountTvaCents: number;
amountTtcCents: number;
};
}) {
const fmt = (cents: number) =>
(cents / 100).toLocaleString("fr-FR", {
style: "currency",
currency: "EUR",
});
return (
<div className="flex flex-col gap-1 self-end rounded-default border border-line bg-cream-2 px-4 py-3 text-[14px] sm:min-w-[280px]">
<div className="flex justify-between text-ink-2">
<span>Total HT</span>
<span className="font-mono tabular-nums">{fmt(totals.amountHtCents)}</span>
</div>
<div className="flex justify-between text-ink-2">
<span>TVA</span>
<span className="font-mono tabular-nums">{fmt(totals.amountTvaCents)}</span>
</div>
<div className="mt-1 flex justify-between border-t border-line pt-2 font-display font-bold text-ink">
<span>Total TTC</span>
<span className="font-mono tabular-nums">{fmt(totals.amountTtcCents)}</span>
</div>
</div>
);
}
// ============================================================================
// computeTotals — mêmes règles d'arrondi que le serveur (cf. invoice_totals.ts)
// ============================================================================
function computeTotals(lines: InvoiceLineInput[]) {
let amountHt = 0;
let amountTva = 0;
for (const line of lines) {
const htCents = Math.round(line.quantity * line.unitPriceCents);
const tvaCents = Math.round((htCents * line.tvaRate) / 100);
amountHt += htCents;
amountTva += tvaCents;
}
return {
amountHtCents: amountHt,
amountTvaCents: amountTva,
amountTtcCents: amountHt + amountTva,
};
}
// ============================================================================
// usePreview — POST /invoices/preview-pdf debounced, objectURL pour iframe
// ============================================================================
function usePreview(fields: {
clientId: string | null;
issueDate: string;
paymentTermsDays: number;
themeSlug: InvoiceThemeSlug;
accentColor: string;
lines: InvoiceLineInput[];
footerNotes: string;
}) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const previousUrlRef = useRef<string | null>(null);
// Debounce 500ms — on attend que la frappe ralentisse avant de POST.
const debounced = useDebouncedValue(fields, 500);
useEffect(() => {
// Pré-conditions : un client sélectionné et au moins une ligne non-vide.
const hasUsableLines = debounced.lines.some(
(l) => l.description.trim() !== "" && l.unitPriceCents > 0,
);
if (!debounced.clientId || !hasUsableLines) {
setPreviewUrl(null);
setPreviewError(null);
return;
}
const ctrl = new AbortController();
setIsRefreshing(true);
setPreviewError(null);
const dueDate = addDays(
new Date(debounced.issueDate),
debounced.paymentTermsDays,
).toISOString();
previewInvoicePdf(
{
clientId: debounced.clientId,
issueDate: new Date(debounced.issueDate).toISOString(),
dueDate,
paymentTermsDays: debounced.paymentTermsDays,
themeSlug: debounced.themeSlug,
accentColor: debounced.accentColor,
lines: debounced.lines,
footerNotes:
debounced.footerNotes.trim() === "" ? null : debounced.footerNotes,
},
ctrl.signal,
)
.then((blob) => {
const url = URL.createObjectURL(blob);
if (previousUrlRef.current) URL.revokeObjectURL(previousUrlRef.current);
previousUrlRef.current = url;
setPreviewUrl(url);
setIsRefreshing(false);
})
.catch((err) => {
if (err?.name === "AbortError") return;
setIsRefreshing(false);
setPreviewError(
err instanceof ApiError
? err.message
: "Aperçu indisponible — vérifiez les champs.",
);
});
return () => ctrl.abort();
}, [debounced]);
// Cleanup au démontage.
useEffect(() => {
return () => {
if (previousUrlRef.current) URL.revokeObjectURL(previousUrlRef.current);
};
}, []);
return { previewUrl, previewError, isRefreshing };
}
/** useDebouncedValue — version générique de debounce sur une valeur. */
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}

View File

@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowRight, CreditCard, Palette } from "lucide-react"; import { ArrowRight, CreditCard, FileText, Palette } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import { SettingsSection } from "@/components/settings/SettingsSection"; import { SettingsSection } from "@/components/settings/SettingsSection";
@ -13,6 +13,7 @@ import { Button } from "@rubis/ui";
import { Card } from "@rubis/ui"; import { Card } from "@rubis/ui";
import { useSubscription } from "@/lib/billing"; import { useSubscription } from "@/lib/billing";
import { useBankingStatus } from "@/lib/banking"; import { useBankingStatus } from "@/lib/banking";
import { useAuth } from "@/lib/auth";
/** /**
* Search params optionnels : * Search params optionnels :
@ -52,6 +53,12 @@ function ParametresPage() {
const { data: bankingStatus } = useBankingStatus(); const { data: bankingStatus } = useBankingStatus();
const showBanking = const showBanking =
bankingStatus?.enabled === true || bankingStatus?.comingSoon === true; bankingStatus?.enabled === true || bankingStatus?.comingSoon === true;
// Mode démo réservé aux admins Rubis (outil de prospection en démo
// commerciale). Un user lambda n'a aucune raison d'y avoir accès — c'est
// une horloge virtuelle + capture des emails, donc complètement déroutant
// si l'user croit que c'est une feature produit.
const { user } = useAuth();
const isAdmin = !!user?.isAdmin;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -126,6 +133,34 @@ function ParametresPage() {
</Card> </Card>
</SettingsSection> </SettingsSection>
<SettingsSection
eyebrow="Facturation"
title={
<>
Vos factures, <em className="text-rubis">votre identité</em>
</>
}
description="Identité émetteur, RIB, mentions légales et thème par défaut pour les factures que vous créez dans Rubis. Mis à jour à la prochaine émission."
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Éditeur de factures
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
Paramétrer la facturation
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/facturation">
<FileText size={14} aria-hidden="true" />
Configurer
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
</Card>
</SettingsSection>
<SettingsSection <SettingsSection
eyebrow="Marque" eyebrow="Marque"
title={ title={
@ -160,11 +195,21 @@ function ParametresPage() {
<SettingsSection <SettingsSection
eyebrow="Banque" eyebrow="Banque"
title={ title={
bankingStatus?.comingSoon ? (
<>
Bientôt : votre <em className="text-rubis">banque</em> connectée à Rubis
</>
) : (
<> <>
Connecter votre <em className="text-rubis">banque</em> Connecter votre <em className="text-rubis">banque</em>
</> </>
)
}
description={
bankingStatus?.comingSoon
? "Nous finalisons notre agrément AISP avec Powens. Une fois ouvert, Rubis lira vos virements entrants pour détecter automatiquement les factures payées — en lecture seule, sans déplacement de fonds."
: "Rubis lit vos virements entrants pour détecter automatiquement les factures payées. Lecture seule, aucun déplacement de fonds. Disponible sur les plans Pro et Business."
} }
description="Rubis lit vos virements entrants pour détecter automatiquement les factures payées. Lecture seule, aucun déplacement de fonds. Disponible sur les plans Pro et Business."
> >
<BankingSection <BankingSection
callbackStatus={search.banking} callbackStatus={search.banking}
@ -173,6 +218,7 @@ function ParametresPage() {
</SettingsSection> </SettingsSection>
)} )}
{isAdmin && (
<SettingsSection <SettingsSection
eyebrow="Démonstration" eyebrow="Démonstration"
title={ title={
@ -184,6 +230,7 @@ function ParametresPage() {
> >
<DemoToggle /> <DemoToggle />
</SettingsSection> </SettingsSection>
)}
<SettingsSection <SettingsSection
eyebrow="Zone danger" eyebrow="Zone danger"

View File

@ -0,0 +1,766 @@
import { useEffect, useMemo, useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import type {
InvoiceIssuer,
InvoiceRib,
InvoiceSettings,
InvoiceThemeSlug,
} from "@rubis/shared";
import {
useInvoiceSettings,
useUpdateInvoiceSettings,
useInvoiceThemes,
} from "@/lib/invoice-settings";
import { SettingsSection } from "@/components/settings/SettingsSection";
import { Button, Card, Eyebrow } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
import { Field } from "@/components/ui/Field";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/_app/parametres_/facturation")({
component: ParametresFacturationPage,
});
/**
* /parametres/facturation paramétrage de l'éditeur de factures natif.
*
* Cinq sections autonomes (chacune son save) :
* 1. Identité émetteur (figée dans chaque facture émise)
* 2. RIB (pied de page paiement)
* 3. Numérotation (préfixe + compteur + padding, prévisualisation live)
* 4. Mentions & délais (texte légal + jours de paiement par défaut)
* 5. Thème par défaut + couleur d'accent
*
* Convention d'enregistrement : chaque section sauve indépendamment via
* un PATCH partiel. Cohérent avec l'idée "modifier ses mentions ne
* sauvegarde pas son RIB" (clarté du blast radius).
*/
function ParametresFacturationPage() {
const { data, isPending } = useInvoiceSettings();
const { data: themes } = useInvoiceThemes();
return (
<div className="flex flex-col gap-2">
<header className="mb-4">
<Button size="sm" variant="ghost" asChild className="mb-3 -ml-2">
<Link to="/parametres">
<ArrowLeft size={14} aria-hidden="true" /> Retour aux paramètres
</Link>
</Button>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Facturation
</h1>
<p className="mt-1.5 text-[14px] text-ink-3">
Identité émetteur, RIB, mentions légales et thème par défaut. Tout est
snapshoté à l'émission — modifier ces paramètres n'altère pas les
factures déjà émises.
</p>
</header>
{isPending || !data ? (
<Card padding="md" className="text-center text-ink-3">
<Loader2 className="mx-auto mb-2 animate-spin" size={20} />
Chargement
</Card>
) : (
<div className="flex flex-col gap-10 lg:gap-12">
<SettingsSection
eyebrow="Identité"
title="Vos informations légales"
description="Nom commercial, adresse, SIREN/SIRET, TVA intracommunautaire et autres mentions obligatoires. Apparaissent dans l'entête de chaque facture émise."
>
<IssuerForm
issuer={(data.settings.issuer ?? {}) as Partial<InvoiceIssuer>}
/>
</SettingsSection>
<SettingsSection
eyebrow="RIB"
title="Coordonnées de paiement"
description="IBAN, BIC et nom de banque affichés en pied de facture pour faciliter le virement entrant."
>
<RibForm rib={(data.settings.rib ?? {}) as Partial<InvoiceRib>} />
</SettingsSection>
<SettingsSection
eyebrow="Numérotation"
title="Préfixe et séquence"
description="Chronologie strictement séquentielle par organisation (art. 242 nonies A du CGI). Le compteur s'incrémente automatiquement à chaque facture émise."
>
<NumeroForm
settings={data.settings}
numeroNextSeq={data.resolved.numeroNextSeq}
numeroPadding={data.resolved.numeroPadding}
numeroPrefix={data.resolved.numeroPrefix}
/>
</SettingsSection>
<SettingsSection
eyebrow="Mentions"
title="Délais & textes légaux"
description="Délai de paiement par défaut, pénalités de retard et escompte (mentions obligatoires Code de commerce art. L441-9 et L441-10)."
>
<MentionsForm
resolvedPaymentTermsDays={data.resolved.paymentTermsDays}
resolvedPenalty={data.resolved.penaltyRateText}
resolvedEscompte={data.resolved.escompteText}
resolvedFooter={data.resolved.footerLegalText}
/>
</SettingsSection>
<SettingsSection
eyebrow="Thème"
title="Apparence par défaut"
description="Choisissez le thème et la couleur d'accent qui s'appliquent par défaut aux nouvelles factures. Modifiable par facture dans l'éditeur."
>
<ThemeForm
themes={themes ?? []}
currentThemeSlug={data.resolved.themeSlug}
currentAccentColor={data.resolved.accentColor}
/>
</SettingsSection>
</div>
)}
</div>
);
}
// ============================================================================
// Section 1 — Identité émetteur
// ============================================================================
function IssuerForm({ issuer }: { issuer: Partial<InvoiceIssuer> }) {
const update = useUpdateInvoiceSettings();
const [draft, setDraft] = useState<Partial<InvoiceIssuer>>(issuer);
useEffect(() => {
setDraft(issuer);
}, [issuer]);
const isDirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(issuer),
[draft, issuer],
);
const set = <K extends keyof InvoiceIssuer>(key: K, value: string) => {
setDraft((d) => ({ ...d, [key]: value.trim() === "" ? null : value }));
};
return (
<Card padding="md" className="flex flex-col gap-5">
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Raison sociale">
<Input
value={draft.companyName ?? ""}
onChange={(e) => set("companyName", e.target.value)}
placeholder="Cabinet Compta Martin"
/>
</Field>
<Field label="Forme juridique" hint="SARL, SAS, EI, EURL…">
<Input
value={draft.formeJuridique ?? ""}
onChange={(e) => set("formeJuridique", e.target.value)}
placeholder="SARL"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Adresse" hint="Numéro et rue">
<Input
value={draft.addressLine1 ?? ""}
onChange={(e) => set("addressLine1", e.target.value)}
placeholder="12 rue du Pain"
/>
</Field>
<Field label="Complément" hint="Bâtiment, étage (optionnel)">
<Input
value={draft.addressLine2 ?? ""}
onChange={(e) => set("addressLine2", e.target.value)}
placeholder="Bâtiment B"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Field label="Code postal">
<Input
value={draft.addressZip ?? ""}
onChange={(e) => set("addressZip", e.target.value)}
placeholder="75011"
/>
</Field>
<Field label="Ville">
<Input
value={draft.addressCity ?? ""}
onChange={(e) => set("addressCity", e.target.value)}
placeholder="Paris"
/>
</Field>
<Field label="Pays" hint="ISO 2 lettres (FR par défaut)">
<Input
value={draft.addressCountry ?? ""}
onChange={(e) => set("addressCountry", e.target.value.toUpperCase())}
placeholder="FR"
maxLength={2}
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="SIREN" hint="9 chiffres">
<Input
value={draft.siren ?? ""}
onChange={(e) => set("siren", e.target.value)}
placeholder="123456789"
inputMode="numeric"
/>
</Field>
<Field label="SIRET" hint="14 chiffres">
<Input
value={draft.siret ?? ""}
onChange={(e) => set("siret", e.target.value)}
placeholder="12345678900012"
inputMode="numeric"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="TVA intracom" hint="Ex. FR12345678901">
<Input
value={draft.tvaIntra ?? ""}
onChange={(e) => set("tvaIntra", e.target.value.toUpperCase())}
placeholder="FR12345678901"
/>
</Field>
<Field label="Code NAF/APE" hint="Ex. 6201Z">
<Input
value={draft.naf ?? ""}
onChange={(e) => set("naf", e.target.value.toUpperCase())}
placeholder="6201Z"
maxLength={5}
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field
label="RCS"
hint="Mention obligatoire si commerçant : ville d'immatriculation"
>
<Input
value={draft.rcs ?? ""}
onChange={(e) => set("rcs", e.target.value)}
placeholder="RCS Paris 123 456 789"
/>
</Field>
<Field
label="Capital"
hint="Mention obligatoire pour les sociétés à capital"
>
<Input
value={draft.capital ?? ""}
onChange={(e) => set("capital", e.target.value)}
placeholder="SARL au capital de 1 000 €"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Email de contact">
<Input
type="email"
value={draft.contactEmail ?? ""}
onChange={(e) => set("contactEmail", e.target.value)}
placeholder="facturation@cabinet-martin.fr"
/>
</Field>
<Field label="Téléphone de contact">
<Input
value={draft.contactPhone ?? ""}
onChange={(e) => set("contactPhone", e.target.value)}
placeholder="+33 1 23 45 67 89"
/>
</Field>
</div>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() => update.mutate({ issuer: draft })}
/>
</Card>
);
}
// ============================================================================
// Section 2 — RIB
// ============================================================================
function RibForm({ rib }: { rib: Partial<InvoiceRib> }) {
const update = useUpdateInvoiceSettings();
const [draft, setDraft] = useState<Partial<InvoiceRib>>(rib);
useEffect(() => setDraft(rib), [rib]);
const isDirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(rib),
[draft, rib],
);
const set = <K extends keyof InvoiceRib>(key: K, value: string) => {
setDraft((d) => ({ ...d, [key]: value.trim() === "" ? null : value }));
};
return (
<Card padding="md" className="flex flex-col gap-5">
<Field label="Nom de la banque">
<Input
value={draft.bankName ?? ""}
onChange={(e) => set("bankName", e.target.value)}
placeholder="BNP Paribas — Agence République"
/>
</Field>
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<Field label="IBAN" hint="Espaces tolérés, normalisés à l'enregistrement">
<Input
value={draft.iban ?? ""}
onChange={(e) => set("iban", e.target.value.toUpperCase())}
placeholder="FR76 1234 5678 9012 3456 7890 123"
/>
</Field>
<Field label="BIC / SWIFT" hint="8 ou 11 caractères">
<Input
value={draft.bic ?? ""}
onChange={(e) => set("bic", e.target.value.toUpperCase())}
placeholder="BNPAFRPPXXX"
/>
</Field>
</div>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() => update.mutate({ rib: draft })}
/>
</Card>
);
}
// ============================================================================
// Section 3 — Numérotation
// ============================================================================
function NumeroForm({
settings,
numeroNextSeq,
numeroPadding,
numeroPrefix,
}: {
settings: InvoiceSettings;
numeroNextSeq: number;
numeroPadding: number;
numeroPrefix: string;
}) {
const update = useUpdateInvoiceSettings();
const [prefix, setPrefix] = useState(numeroPrefix);
const [nextSeq, setNextSeq] = useState(numeroNextSeq);
const [padding, setPadding] = useState(numeroPadding);
useEffect(() => setPrefix(numeroPrefix), [numeroPrefix]);
useEffect(() => setNextSeq(numeroNextSeq), [numeroNextSeq]);
useEffect(() => setPadding(numeroPadding), [numeroPadding]);
const isDirty =
prefix !== numeroPrefix ||
nextSeq !== numeroNextSeq ||
padding !== numeroPadding;
const preview = `${prefix}${String(nextSeq).padStart(padding, "0")}`;
return (
<Card padding="md" className="flex flex-col gap-5">
<div className="grid gap-4 lg:grid-cols-3">
<Field label="Préfixe" hint="Ex. FAC-2026-, INV-, F-, …">
<Input
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
placeholder="FAC-2026-"
maxLength={40}
/>
</Field>
<Field
label="Prochain numéro"
hint={
settings.numeroNextSeq === undefined || settings.numeroNextSeq === null
? "Modifiable une fois pour reprendre une séquence existante"
: "Auto-incrémenté à chaque facture émise"
}
>
<Input
type="number"
value={nextSeq}
onChange={(e) => setNextSeq(Number(e.target.value) || 1)}
min={1}
max={9_999_999}
/>
</Field>
<Field label="Padding" hint="Zéros de tête (0042 = 4)">
<Input
type="number"
value={padding}
onChange={(e) => setPadding(Number(e.target.value) || 1)}
min={1}
max={10}
/>
</Field>
</div>
<div className="rounded-default border border-line bg-cream-2 px-4 py-3">
<Eyebrow>Aperçu</Eyebrow>
<p className="mt-1 font-mono text-[18px] font-bold text-ink">{preview}</p>
<p className="mt-1 text-[12.5px] text-ink-3">
Le numéro suivant sera{" "}
<span className="font-mono">
{prefix}
{String(nextSeq + 1).padStart(padding, "0")}
</span>
.
</p>
</div>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() =>
update.mutate({
numeroPrefix: prefix,
numeroNextSeq: nextSeq,
numeroPadding: padding,
})
}
/>
</Card>
);
}
// ============================================================================
// Section 4 — Mentions & délais
// ============================================================================
function MentionsForm({
resolvedPaymentTermsDays,
resolvedPenalty,
resolvedEscompte,
resolvedFooter,
}: {
resolvedPaymentTermsDays: number;
resolvedPenalty: string;
resolvedEscompte: string;
resolvedFooter: string;
}) {
const update = useUpdateInvoiceSettings();
const [days, setDays] = useState(resolvedPaymentTermsDays);
const [penalty, setPenalty] = useState(resolvedPenalty);
const [escompte, setEscompte] = useState(resolvedEscompte);
const [footer, setFooter] = useState(resolvedFooter);
useEffect(() => setDays(resolvedPaymentTermsDays), [resolvedPaymentTermsDays]);
useEffect(() => setPenalty(resolvedPenalty), [resolvedPenalty]);
useEffect(() => setEscompte(resolvedEscompte), [resolvedEscompte]);
useEffect(() => setFooter(resolvedFooter), [resolvedFooter]);
const isDirty =
days !== resolvedPaymentTermsDays ||
penalty !== resolvedPenalty ||
escompte !== resolvedEscompte ||
footer !== resolvedFooter;
return (
<Card padding="md" className="flex flex-col gap-5">
<Field
label="Délai de paiement (jours)"
hint="Loi LME : plafond à 60 jours ou 45 jours fin de mois entre professionnels."
>
<Input
type="number"
value={days}
onChange={(e) => setDays(Number(e.target.value) || 0)}
min={0}
max={365}
className="lg:max-w-[200px]"
/>
</Field>
<Field
label="Pénalités de retard"
hint="Obligatoire (art. L441-10 du Code de commerce)"
>
<Textarea
value={penalty}
onChange={(e) => setPenalty(e.target.value)}
rows={3}
maxLength={1000}
/>
</Field>
<Field
label="Escompte pour paiement anticipé"
hint="Obligatoire (art. L441-9 du Code de commerce)"
>
<Textarea
value={escompte}
onChange={(e) => setEscompte(e.target.value)}
rows={2}
maxLength={500}
/>
</Field>
<Field
label="Mention libre additionnelle"
hint="Texte affiché en pied de page (CGV, mentions spécifiques métier…)"
>
<Textarea
value={footer}
onChange={(e) => setFooter(e.target.value)}
rows={3}
maxLength={1000}
placeholder="Vos CGV sont disponibles sur demande à contact@…"
/>
</Field>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() =>
update.mutate({
paymentTermsDays: days,
// Null si l'utilisateur a vidé le champ → reset au default Rubis.
penaltyRateText: penalty.trim() === "" ? null : penalty,
escompteText: escompte.trim() === "" ? null : escompte,
footerLegalText: footer.trim() === "" ? null : footer,
})
}
/>
</Card>
);
}
// ============================================================================
// Section 5 — Thème & accent
// ============================================================================
function ThemeForm({
themes,
currentThemeSlug,
currentAccentColor,
}: {
themes: { slug: InvoiceThemeSlug; name: string; description: string }[];
currentThemeSlug: InvoiceThemeSlug;
currentAccentColor: string;
}) {
const update = useUpdateInvoiceSettings();
const [slug, setSlug] = useState<InvoiceThemeSlug>(currentThemeSlug);
const [accent, setAccent] = useState(currentAccentColor);
useEffect(() => setSlug(currentThemeSlug), [currentThemeSlug]);
useEffect(() => setAccent(currentAccentColor), [currentAccentColor]);
const isDirty = slug !== currentThemeSlug || accent !== currentAccentColor;
return (
<Card padding="md" className="flex flex-col gap-5">
<div>
<Eyebrow>Galerie</Eyebrow>
<div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{themes.map((t) => (
<button
key={t.slug}
type="button"
onClick={() => setSlug(t.slug)}
className={cn(
"flex flex-col gap-2 rounded-default border bg-white p-3 text-left transition-colors",
slug === t.slug
? "border-rubis ring-4 ring-rubis-glow"
: "border-line hover:border-rubis-light",
)}
>
<ThemePreview themeSlug={t.slug} accentColor={accent} />
<div className="flex items-center justify-between">
<span className="font-display text-[14px] font-bold text-ink">
{t.name}
</span>
{slug === t.slug ? (
<Check size={16} className="text-rubis" aria-hidden="true" />
) : null}
</div>
<p className="text-[12px] leading-snug text-ink-3">{t.description}</p>
</button>
))}
</div>
</div>
<Field
label="Couleur d'accent"
hint="Hex #RRGGBB — utilisée sur le numéro, les filets et le total TTC selon le thème."
>
<div className="flex items-center gap-3">
<input
type="color"
value={accent}
onChange={(e) => setAccent(e.target.value)}
className="h-11 w-16 cursor-pointer rounded-default border border-line bg-white p-1"
aria-label="Choisir la couleur d'accent"
/>
<Input
value={accent}
onChange={(e) => setAccent(e.target.value)}
placeholder="#9F1239"
maxLength={7}
className="font-mono lg:max-w-[160px]"
/>
</div>
</Field>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() =>
update.mutate({
themeSlug: slug,
accentColor: accent,
})
}
/>
</Card>
);
}
/**
* Preview miniature d'un thème wireframe simplifié rendu en pur CSS
* (pas de PDF embed dans la galerie : trop lourd, trop lent à scroll).
* Reflète l'esprit du template (bandeau, hairline, etc.).
*/
function ThemePreview({
themeSlug,
accentColor,
}: {
themeSlug: InvoiceThemeSlug;
accentColor: string;
}) {
const accent = { backgroundColor: accentColor };
const accentBorder = { borderColor: accentColor };
if (themeSlug === "classique") {
return (
<div className="aspect-[1/1.4] rounded-sm border border-line bg-paper p-2.5 text-[6px]">
<div className="space-y-0.5 text-center">
<div className="mx-auto h-1 w-6 rounded-sm bg-ink-3" />
<div className="text-[6px] font-bold text-ink">Société</div>
<div className="text-ink-3">12 rue · 75001 Paris</div>
</div>
<div className="my-1.5 border-b" style={accentBorder} />
<div className="font-bold text-ink-2" style={{ color: accentColor }}>
FACTURE
</div>
<div className="mt-1 h-0.5 w-full bg-line" />
<div className="mt-0.5 h-0.5 w-3/4 bg-line" />
<div className="mt-0.5 h-0.5 w-2/3 bg-line" />
</div>
);
}
if (themeSlug === "moderne") {
return (
<div className="aspect-[1/1.4] overflow-hidden rounded-sm border border-line bg-paper text-[6px]">
<div className="px-2 py-1.5 text-white" style={accent}>
<div className="text-[8px] font-bold">FACTURE</div>
<div className="opacity-80">N° FAC-2026-0042</div>
</div>
<div className="p-2 space-y-0.5">
<div className="h-0.5 w-3/4 bg-line" />
<div className="h-0.5 w-2/3 bg-line" />
<div className="h-0.5 w-1/2 bg-line" />
</div>
</div>
);
}
if (themeSlug === "minimal") {
return (
<div className="aspect-[1/1.4] rounded-sm border border-line bg-paper p-3 text-[6px]">
<div className="flex justify-between">
<div className="font-bold text-ink">Société</div>
<div className="font-bold" style={{ color: accentColor }}>
FAC-2026
</div>
</div>
<div className="mt-2 space-y-0.5">
<div className="h-0.5 w-3/4 bg-line" />
<div className="h-0.5 w-2/3 bg-line" />
</div>
<div className="mt-3 text-right text-[7px] font-bold text-ink"></div>
</div>
);
}
// elegant
return (
<div className="aspect-[1/1.4] rounded-sm border border-line bg-paper p-2 text-[6px]">
<div className="border-b" style={accentBorder} />
<div className="mt-1 text-center">
<div className="text-[5px] uppercase tracking-widest" style={{ color: accentColor }}>
Facture
</div>
<div className="text-[7px] font-bold italic text-ink">N° FAC-0042</div>
</div>
<div className="mt-1 border-b" style={accentBorder} />
<div className="mt-1.5 space-y-0.5">
<div className="h-0.5 w-full bg-line" />
<div className="h-0.5 w-3/4 bg-line" />
<div className="h-0.5 w-2/3 bg-line" />
</div>
</div>
);
}
// ============================================================================
// SaveBar — footer commun à toutes les sections
// ============================================================================
function SaveBar({
isDirty,
isPending,
error,
onSave,
}: {
isDirty: boolean;
isPending: boolean;
error: Error | null;
onSave: () => void;
}) {
return (
<div className="flex items-center justify-end gap-3 border-t border-line pt-4">
{error ? (
<p className="mr-auto text-[13px] font-medium text-rubis-deep">
{error.message}
</p>
) : null}
<Button onClick={onSave} disabled={!isDirty || isPending} size="sm">
{isPending ? (
<>
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
Enregistrement
</>
) : (
"Enregistrer"
)}
</Button>
</div>
);
}

View File

@ -362,6 +362,38 @@
--- ---
## ADR-025 · Édition native des factures + roadmap Factur-X
- **Date** : 2026-05-14
- **Statut** : ✅ Validée (V1.1)
- **Contexte** : utilisateurs cibles TPE-PME — beaucoup n'ont pas d'outil de facturation et émettent leurs factures à la main (Word, Excel, parfois rien). Friction à l'adoption de Rubis : "je dois d'abord créer ma facture ailleurs, puis l'uploader ici". Question : Rubis peut-il aussi émettre les factures, ou rester pure-player relance ?
- **Décision** : ajouter une **édition native des factures** en V1.1 (`/factures/nouvelle`) comme **extension douce** au cœur relance, **pas** comme pivot vers un outil de facturation complet. Périmètre V1.1 minimal : factures simples avec lignes, TVA, 4 thèmes pré-faits, numérotation strict séquentielle, snapshots immuables. Pas de devis, pas d'avoirs, pas d'acomptes, pas de récurrence (tout V2+).
- **Rationale** :
- **Marché adressable élargi** : on capture les TPE-PME qui n'ont aucun outil de facturation (segment "Excel" ou "Word + papier"). Coûte peu de complexité produit et ne nous met pas en concurrence frontale avec Pennylane/Sellsy (qui font CRM + comptabilité + multi-fonctions).
- **Cohérence avec la promesse** : la relance reste l'âme. La création de facture est un *moyen* pour amener plus vite à la relance ("vous créez, on relance"), pas une feature de premier rang dans la com publique.
- **Snapshots immuables** : une facture émise ne change jamais — c'est une preuve comptable. `client_snapshot` et `issuer_snapshot` figés à l'émission, le PDF stocké sur MinIO. Modifier l'adresse du client ou les settings de l'org n'altère pas les factures déjà émises.
- **Numérotation strict séquentielle** : l'art. 242 nonies A du CGI exige une chronologie continue, sans rupture. Compteur per-org alloué en transaction avec `SELECT FOR UPDATE` sur la ligne `organizations`, brouillons exclus (qui peuvent être supprimés sans créer de gap). Choisi vs flexible parce que la conformité prime sur l'ergonomie de "je veux mettre n'importe quel numéro".
- **Génération PDF côté serveur via `@react-pdf/renderer`** : composants TSX dans `apps/api/app/pdf-templates/`, dispatcher par slug. Léger (pas de Chromium dans K3s, image Docker reste petite), composants React → preview client possible plus tard si nécessaire. Pour V1.1, la preview web passe par `POST /invoices/preview-pdf` → Blob → objectURL → iframe (debounce 500 ms côté éditeur). Single source of truth pour le rendu.
- **4 thèmes pré-faits + accent paramétrable** : couvre 80 % des besoins esthétiques. Plus simple à livrer qu'un éditeur WYSIWYG drag-and-drop, plus différenciant qu'un seul template.
- **Roadmap conformité Factur-X (réforme 2026-2027)** :
- **V1 (maintenant)** : PDF classique avec mentions fr-FR complètes (pénalités L441-10, escompte L441-9, identité émetteur). Suffit jusqu'à l'échéance d'émission TPE-PME au **1er septembre 2027**.
- **V1.5 (Q3-Q4 2026)** : génération **Factur-X natif** (PDF/A-3 + XML CII embarqué). On reste l'émetteur direct, pas besoin de devenir PDP. Compatible avec les PDP des destinataires (réception côté ETI/GE obligatoire 1er sept 2026).
- **V2 (S1 2027)** : intégration **PDP partenaire** pour la transmission via le PPF si demandes clients. Choix du partenaire (Pennylane Connect, Cegid, Tiime…) à benchmarker au moment.
- **Alternatives écartées** :
- **Rester pure-player relance** : laisse de côté un segment significatif (TPE qui facturent dans Excel). On gagne en simplicité mais on perd en TAM.
- **Pivot complet vers facturation** : énorme chantier (devis, avoirs, acomptes, récurrence, multi-TVA, multi-devises, FEC, e-invoicing PDP), concurrence frontale avec des outils établis, dilue la promesse "relance toutes seules".
- **Mode brouillon interne uniquement** (factures non-légales, watermark "Brouillon") : pas de valeur réelle — un utilisateur qui édite chez Rubis veut envoyer la facture au client, pas un brouillon.
- **Éditeur WYSIWYG drag-and-drop** : équivalent d'un Figma simplifié dans le navigateur, plusieurs mois de boulot. Galerie de 4 thèmes pré-faits couvre l'usage avec une fraction du coût.
- **Pas de Factur-X** : ignorer la réforme = laisser nos clients en infraction au 1er sept 2027. Inacceptable.
- **Intégration PDP partenaire en V1** : prématuré (PDP encore peu matures), coûts récurrents partagés, dépendance forte. À reconsidérer si le marché se consolide d'ici 2027.
- **Conséquences** :
- Le `CLAUDE.md` est nuancé : "*La relance reste l'âme du produit*" remplace "*Pure-player relance*". La V1.1 est une extension, pas un pivot.
- Nouvelle stack côté API : `@react-pdf/renderer` ajouté aux dependencies. Image Docker grossit peu (pas de Chromium).
- Nouvelle table jamais : tout passe par enrichissement de `invoices` (jsonb) et `organizations.invoice_settings` (jsonb). Migrations rétro-compatibles (factures OCR existantes restent intactes, `is_native = false`).
- Marketing : positionnement reste "Vos factures relancées toutes seules". L'éditeur natif est un *feature secondaire* dans la landing, pas la tagline.
---
## Décisions à venir (en attente) ## Décisions à venir (en attente)
| # | Sujet | Pourquoi en attente | | # | Sujet | Pourquoi en attente |

View File

@ -498,16 +498,73 @@ En dev local, exposer le webhook via `stripe listen --forward-to localhost:3333/
--- ---
## 11bis. Édition native des factures (V1.1)
> Pour le rationale, voir ADR-025. Ici on documente le flow.
### 11bis.1 Trois sources d'une facture dans Rubis
| Source | Origine | Drapeau DB | PDF | Snapshots |
|---|---|---|---|---|
| **OCR** | Upload drag-and-drop, extraction Mindee/Document AI | `is_native = false` | Fichier source uploadé (`pdf_storage_key`) | aucun |
| **Saisie manuelle** | `ManualInvoiceDialog`, 6 champs | `is_native = false` | `pdf_storage_key = null` (pas de fichier) | aucun |
| **Native (V1.1)** | Éditeur `/factures/nouvelle` | `is_native = true` | Généré côté serveur, stocké MinIO | `client_snapshot` + `issuer_snapshot` figés |
Les statuts (`pending`, `in_relance`, `paid`…) et le cycle de vie (cf. §3) sont **identiques** pour les 3 sources. La distinction est UX / présentation : une facture native peut être ré-éditée (avec re-génération PDF) tant qu'elle n'est pas dans un relance déclenchée ; une OCR ne peut pas.
### 11bis.2 Création (flow utilisateur)
1. `/factures` → clic "Créer une facture" (bouton primaire à côté de "Importer").
2. `/factures/nouvelle` : split-view. À gauche, formulaire ; à droite, iframe PDF live (debounce 500 ms via `POST /api/v1/invoices/preview-pdf`).
3. L'utilisateur saisit : client (combobox autocomplete), dates (émission + délai → échéance calculée), plan de relance (optionnel), thème + accent, lignes (désignation/qté/PU/TVA), notes pied de page.
4. Le serveur recalcule HT/TVA/TTC à chaque preview (jamais confiance au client). Le front affiche aussi un total live en local pour feedback instantané (mêmes règles d'arrondi : `Math.round` par ligne).
5. Deux boutons en footer :
- **Enregistrer en brouillon**`POST /invoices/native` avec `draft: true`. Statut `pending`, `sequence_number = null`, `numero = "BROUILLON-XXXX"`. Pas de check-in programmé.
- **Émettre la facture**`POST /invoices/native` avec `draft: false`. Alloue le prochain numéro de la séquence (`sequence_number = N`, `numero = "<prefix>0042"`) en transaction avec verrou `FOR UPDATE` sur la ligne `organizations`. Programme le check-in si un plan est associé.
### 11bis.3 Génération PDF
- Templates dans `apps/api/app/pdf-templates/` (4 fichiers TSX + `common.tsx` + `index.tsx` dispatcher).
- Pipeline : `renderInvoiceToBuffer(themeSlug, props)``@react-pdf/renderer.renderToBuffer``uploadBuffer(buf, 'invoice-pdf', orgId)``invoices/<orgId>/<uuid>.pdf` sur MinIO.
- Snapshot `client_snapshot` lu en priorité, fallback sur le client live pour la preview (qui n'a pas encore figé les snapshots).
- Lazy regenerate : si `GET /invoices/:id/pdf` reçoit une facture native sans `pdf_storage_key` (génération échouée au store), on retente à la volée et on persiste.
### 11bis.4 Numérotation (point sensible légalement)
- Compteur dans `organizations.invoice_settings.numeroNextSeq` (JSONB).
- Allocation : `SELECT invoice_settings FROM organizations WHERE id = $1 FOR UPDATE` → lit le compteur, increment, écrit `invoice_settings = jsonb_set(...)`. Garantit l'unicité même sous concurrence (deux onglets, deux jobs).
- Brouillons : `numero` éphémère type `BROUILLON-A1B2C3D4`, `sequence_number = null`. Aucun risque de gap si l'utilisateur supprime un brouillon (la séquence n'a pas été allouée).
- Unicité : `UNIQUE (organization_id, numero)` + `UNIQUE (organization_id, sequence_number)` (partial, autorise plusieurs `NULL`).
- Override initial : l'utilisateur peut définir `numeroNextSeq = 42` dans les settings une fois (pour reprendre une séquence existante d'un autre outil). Au-delà, c'est auto-incrémenté.
### 11bis.5 Snapshots (immutabilité)
À l'émission d'une facture native, deux JSONB sont gelés :
- `client_snapshot` : nom, email, contact, SIREN/SIRET, TVA intra, adresse structurée du client tel qu'il était.
- `issuer_snapshot` : identité émetteur tel qu'elle était dans `invoice_settings.issuer`.
Si plus tard le client déménage ou si l'org modifie son SIRET, **les factures déjà émises restent identiques** au PDF stocké. Cf. ADR-025 pour le rationale (preuve comptable).
### 11bis.6 Cas limites
- **Plan limite Free atteint** (5 factures actives) : `POST /invoices/native` renvoie 402 `plan_limit_reached` — même règle que la saisie OCR/manuelle.
- **Lignes vides** : le SPA bloque le submit si une ligne n'a pas de description ou si `unitPriceCents < 0`.
- **Génération PDF échoue post-store** : on log, on continue (la facture est créée), `pdf_storage_key = null`. Le prochain `GET /:id/pdf` regénère lazy.
- **Aperçu live et client inexistant** : le SPA ne POST pas tant qu'on n'a pas de `clientId` (combobox doit avoir sélectionné une fiche).
- **Race condition sur la séquence** : verrou row-level sur `organizations` → sérialise les `storeNative` concurrents pour la même org. Pas de gaps possibles hors brouillons supprimés (et les brouillons ne consomment pas la séquence).
## 12. Ce que Rubis ne fait PAS (rappel) ## 12. Ce que Rubis ne fait PAS (rappel)
| Hors-scope | Pourquoi | | Hors-scope | Pourquoi |
|---|---| |---|---|
| Émettre des factures | On n'est pas un Henrri-bis. On relance ce qui sort d'ailleurs. | | ~~Émettre des factures~~ → édité V1.1 : on émet maintenant des factures natives (cf. §11bis et ADR-025), mais on n'est toujours pas un outil de facturation complet (pas d'avoirs, pas de devis, pas d'acomptes en V1.1). | Extension douce, pas pivot. |
| Devis, avoirs (credit notes), acomptes, facturation récurrente | V2+. La V1.1 reste minimale (factures simples uniquement). |
| Réconciliation banking auto | V2+. V1 = check-in email. | | Réconciliation banking auto | V2+. V1 = check-in email. |
| Relancer par SMS | V2 (réservé plan le plus cher). | | Relancer par SMS | V2 (réservé plan le plus cher). |
| Multi-utilisateurs | V2 (plans payants seulement). | | Multi-utilisateurs | V2 (plans payants seulement). |
| CRM / pipeline commercial | On reste pure-player relance. | | CRM / pipeline commercial | On reste pure-player relance. |
| Recouvrement contentieux | Hors-scope définitif. La mise en demeure est le seuil. Au-delà, c'est huissier. | | Recouvrement contentieux | Hors-scope définitif. La mise en demeure est le seuil. Au-delà, c'est huissier. |
| Émission Factur-X / transmission via PDP (réforme 2026-2027) | V1.5 (Factur-X natif) puis V2 (PDP partenaire si demande client) — cf. ADR-025. |
--- ---

View File

@ -59,6 +59,21 @@ Rubis Sur l'Ongle libère le temps des dirigeants de TPE-PME en automatisant la
- **Liste filtrable** : par statut (toutes, à relancer, en relance, encaissées, litige) via chips - **Liste filtrable** : par statut (toutes, à relancer, en relance, encaissées, litige) via chips
- **Actions en lot** : checkboxes pour relancer manuellement, changer le plan, archiver - **Actions en lot** : checkboxes pour relancer manuellement, changer le plan, archiver
### 4.2bis Édition native des factures (V1.1, extension douce)
> **Cadre** : Rubis reste un *pure-player relance*. L'édition native s'adresse aux utilisateurs sans outil de facturation existant — ce n'est pas un pivot vers une concurrence frontale avec Pennylane/Sellsy. Voir ADR-025 pour le rationale et la roadmap Factur-X.
- **Éditeur split-view** `/factures/nouvelle` : panneau d'édition à gauche, preview PDF en live à droite (debounce 500 ms via `POST /api/v1/invoices/preview-pdf`).
- **Lignes structurées** : désignation, quantité (décimale autorisée pour heures/demi-jours), prix unitaire HT, taux de TVA (`0`, `2.1`, `5.5`, `10`, `20`). Le serveur recalcule HT/TVA/TTC + ventilation par taux ; les totaux côté client utilisent les mêmes règles d'arrondi pour feedback instantané.
- **Numérotation strict séquentielle** : préfixe + compteur incrémental par organisation (ex. `FAC-2026-0042`), alloué en transaction avec verrou row-level pour éviter les doublons sous concurrence. Brouillons exclus du compteur. Conforme art. 242 nonies A du CGI.
- **4 thèmes pré-faits** : *Classique* (sobre, cabinets/professions réglementées), *Moderne* (bandeau coloré, agences/studios), *Minimal* (noir & blanc, indépendants/designers), *Élégant* (Times Roman, boutiques premium). Couleur d'accent paramétrable. Galerie de sélection avec previews CSS miniatures dans `/parametres/facturation`.
- **Snapshots immuables** : à l'émission, le client (`client_snapshot`) et l'émetteur (`issuer_snapshot`) sont copiés dans la facture. Modifier ses settings ou le client n'altère jamais une facture déjà émise (exigence comptable).
- **Page paramètres** `/parametres/facturation` : 5 sections autonomes (chacune son Save) — identité émetteur (SIREN, SIRET, TVA intracom, RCS, capital, NAF, adresse structurée), RIB (IBAN normalisé + BIC + banque), numérotation (préfixe + compteur + padding avec aperçu live), mentions légales (délai par défaut + pénalités L441-10 + escompte L441-9 + texte libre), thème + accent.
- **Plan de relance optionnel** : si associé à l'émission, Rubis programme le check-in puis les relances à l'échéance — mécanique identique à l'OCR.
- **Brouillon** : créer une facture sans consommer le compteur (numéro éphémère `BROUILLON-XXXX`), pour préparer plusieurs factures avant d'émettre.
- **Génération PDF côté serveur** via `@react-pdf/renderer` (4 composants TSX dans `apps/api/app/pdf-templates/`). PDF stocké sur MinIO (`invoices/<orgId>/<uuid>.pdf`) et exposé via `GET /api/v1/invoices/:id/pdf`. Lazy regenerate si la génération initiale a échoué.
- **Roadmap conformité Factur-X** : V1 = PDF classique. **V1.5 (Q3-Q4 2026)** = Factur-X natif (PDF/A-3 + XML CII embarqué). **V2 (S1 2027)** = intégration PDP partenaire si demandes clients, avant l'échéance d'émission TPE-PME au 1er septembre 2027.
### 4.3 Plans de relance ### 4.3 Plans de relance
- **Bibliothèque** : 4 plans pré-fournis par défaut (cf. `apps/api/app/services/default_plans.ts`) - **Bibliothèque** : 4 plans pré-fournis par défaut (cf. `apps/api/app/services/default_plans.ts`)

View File

@ -136,6 +136,7 @@ pnpm build # build api + web pour prod
- **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins) - **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins)
- **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup) - **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup)
- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads - **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 ### Conventions de routes
Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1. Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1.
@ -152,13 +153,18 @@ GET /api/v1/organizations/:id
GET /api/v1/invoices GET /api/v1/invoices
POST /api/v1/invoices # create manual 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 POST /api/v1/invoices/upload # OCR pipeline
GET /api/v1/invoices/:id GET /api/v1/invoices/:id
PATCH /api/v1/invoices/:id PATCH /api/v1/invoices/:id
DELETE /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/relance # relance manuelle
POST /api/v1/invoices/:id/mark-paid POST /api/v1/invoices/:id/mark-paid
GET /api/v1/invoice-themes # liste des 4 thèmes (ADR-025)
GET /api/v1/plans GET /api/v1/plans
POST /api/v1/plans POST /api/v1/plans
PATCH /api/v1/plans/:id PATCH /api/v1/plans/:id
@ -168,6 +174,9 @@ GET /api/v1/clients
POST /api/v1/clients POST /api/v1/clients
PATCH /api/v1/clients/:id 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/kpis
GET /api/v1/dashboard/activity GET /api/v1/dashboard/activity
``` ```
@ -439,6 +448,49 @@ 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. **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 ### 6.2 Programmation des relances
``` ```

View File

@ -44,3 +44,69 @@ export const ACCEPTED_INVOICE_MIME_TYPES = [
] as const; ] as const;
export const MAX_INVOICE_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 Mo export const MAX_INVOICE_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 Mo
/**
* Thèmes disponibles pour l'éditeur de factures natif.
*
* Chaque thème est un template React rendu par @react-pdf/renderer (cf.
* packages/ui/invoice-templates/). L'utilisateur choisit un thème par défaut
* dans /parametres/facturation, puis peut le surcharger par facture. La
* couleur d'accent est paramétrable séparément (hérite par défaut de
* `brand_settings.primaryColor` ou du rubis #9F1239).
*
* Le slug est snapshotté dans `invoices.theme_slug` à l'émission pour que
* le PDF reste reproductible même si on ajoute / retire un thème plus tard.
*/
export const INVOICE_THEME_SLUGS = [
"classique",
"moderne",
"minimal",
"elegant",
] as const;
/**
* Métadonnées des thèmes affichées dans la galerie de sélection.
* Le rendu lui-même est dans packages/ui/invoice-templates/<slug>.tsx.
*/
export const INVOICE_THEMES = [
{
slug: "classique",
name: "Classique",
description: "Sobre et sérieux, header texte centré. Pour les cabinets et professions réglementées.",
},
{
slug: "moderne",
name: "Moderne",
description: "Bandeau coloré en header, typo Bricolage. Pour les agences et studios.",
},
{
slug: "minimal",
name: "Minimal",
description: "Noir et blanc, aéré, aucun ornement. Pour les indépendants et les designers.",
},
{
slug: "elegant",
name: "Élégant",
description: "Filets fins, watermark logo discret. Pour les boutiques premium et l'artisanat.",
},
] as const;
/**
* Taux de TVA français standards (en pourcent). Le 0% couvre les exonérations
* (auto-entrepreneur sous le seuil de franchise, factures intracom B2B avec
* mention "TVA non applicable, art. 293 B du CGI" ou autoliquidation).
*/
export const FRENCH_TVA_RATES = [0, 2.1, 5.5, 10, 20] as const;
/** Valeurs par défaut pour les settings facturation d'une org neuve. */
export const INVOICE_SETTINGS_DEFAULTS = {
themeSlug: "classique" as const,
paymentTermsDays: 30,
numeroPadding: 4,
addressCountry: "FR",
/** Mention pénalités obligatoire (art. L441-10 du Code de commerce). */
penaltyRateText:
"En cas de retard de paiement, des pénalités de retard sont exigibles au taux annuel équivalent à trois fois le taux d'intérêt légal. Une indemnité forfaitaire pour frais de recouvrement de 40 € s'applique également (art. D441-5 du Code de commerce).",
/** Mention escompte obligatoire (art. L441-9 du Code de commerce). */
escompteText: "Pas d'escompte consenti pour paiement anticipé.",
} as const;

View File

@ -3,12 +3,15 @@ export * from "./types/auth.js";
export * from "./types/user.js"; export * from "./types/user.js";
export * from "./types/client.js"; export * from "./types/client.js";
export * from "./types/invoice.js"; export * from "./types/invoice.js";
export * from "./types/invoice-settings.js";
export * from "./types/invoice-theme.js";
export * from "./types/plan.js"; export * from "./types/plan.js";
// Schemas // Schemas
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./schemas/client.js"; export * from "./schemas/client.js";
export * from "./schemas/invoice.js"; export * from "./schemas/invoice.js";
export * from "./schemas/invoice-settings.js";
export * from "./schemas/plan.js"; export * from "./schemas/plan.js";
// Constants // Constants

View File

@ -12,6 +12,27 @@ export const createClientSchema = z.object({
.regex(/^\d{14}$/u, "Le SIRET doit contenir 14 chiffres") .regex(/^\d{14}$/u, "Le SIRET doit contenir 14 chiffres")
.nullable() .nullable()
.optional(), .optional(),
siren: z
.string()
.regex(/^\d{9}$/u, "Le SIREN doit contenir 9 chiffres")
.nullable()
.optional(),
// Pas de regex stricte — un numéro UE non-FR a un format variable (DE9, BE10…).
// On limite juste à 4-20 chars alphanumériques pour bloquer les inputs absurdes.
tvaIntra: z
.string()
.regex(/^[A-Z]{2}[A-Z0-9]{2,18}$/u, "Format TVA intracom invalide (ex. FR12345678901)")
.nullable()
.optional(),
addressLine1: z.string().max(200).nullable().optional(),
addressLine2: z.string().max(200).nullable().optional(),
addressZip: z.string().max(20).nullable().optional(),
addressCity: z.string().max(100).nullable().optional(),
addressCountry: z
.string()
.regex(/^[A-Z]{2}$/u, "Code pays ISO 2 lettres (ex. FR)")
.nullable()
.optional(),
notes: z.string().max(2000).nullable().optional(), notes: z.string().max(2000).nullable().optional(),
}); });

View File

@ -0,0 +1,85 @@
import { z } from "zod";
import { INVOICE_THEME_SLUGS } from "../constants/index.js";
const HEX_RE = /^#[0-9a-fA-F]{6}$/u;
export const invoiceThemeSlugSchema = z.enum(INVOICE_THEME_SLUGS);
export const invoiceIssuerSchema = z.object({
companyName: z.string().max(200).nullable().optional(),
addressLine1: z.string().max(200).nullable().optional(),
addressLine2: z.string().max(200).nullable().optional(),
addressZip: z.string().max(20).nullable().optional(),
addressCity: z.string().max(100).nullable().optional(),
addressCountry: z
.string()
.regex(/^[A-Z]{2}$/u, "Code pays ISO 2 lettres (ex. FR)")
.nullable()
.optional(),
siren: z
.string()
.regex(/^\d{9}$/u, "SIREN = 9 chiffres")
.nullable()
.optional(),
siret: z
.string()
.regex(/^\d{14}$/u, "SIRET = 14 chiffres")
.nullable()
.optional(),
tvaIntra: z
.string()
.regex(/^[A-Z]{2}[A-Z0-9]{2,18}$/u, "Format TVA intracom invalide")
.nullable()
.optional(),
rcs: z.string().max(120).nullable().optional(),
capital: z.string().max(120).nullable().optional(),
formeJuridique: z.string().max(40).nullable().optional(),
naf: z
.string()
.regex(/^\d{4}[A-Z]$/u, "Code NAF/APE invalide (ex. 6201Z)")
.nullable()
.optional(),
contactEmail: z.string().email().nullable().optional(),
contactPhone: z.string().max(40).nullable().optional(),
});
export const invoiceRibSchema = z.object({
// IBAN max théorique = 34 chars (alphanumérique sans espaces). On accepte
// les espaces côté input et on les strip côté service avant stockage.
iban: z
.string()
.regex(/^[A-Z0-9 ]{15,40}$/u, "IBAN invalide")
.nullable()
.optional(),
bic: z
.string()
.regex(/^[A-Z0-9]{8}([A-Z0-9]{3})?$/u, "BIC/SWIFT invalide (8 ou 11 caractères)")
.nullable()
.optional(),
bankName: z.string().max(120).nullable().optional(),
});
/**
* PATCH /organizations/me/invoice-settings partial.
* Une clé à `null` explicite = reset au default sur ce champ précis.
* Une clé absente = laisse intact.
*/
export const updateInvoiceSettingsSchema = z.object({
themeSlug: invoiceThemeSlugSchema.optional(),
accentColor: z
.string()
.regex(HEX_RE, "Couleur d'accent invalide (#RRGGBB attendu)")
.nullable()
.optional(),
numeroPrefix: z.string().max(40).nullable().optional(),
numeroNextSeq: z.number().int().min(1).max(9999999).nullable().optional(),
numeroPadding: z.number().int().min(1).max(10).nullable().optional(),
paymentTermsDays: z.number().int().min(0).max(365).nullable().optional(),
penaltyRateText: z.string().max(1000).nullable().optional(),
escompteText: z.string().max(500).nullable().optional(),
footerLegalText: z.string().max(1000).nullable().optional(),
issuer: invoiceIssuerSchema.nullable().optional(),
rib: invoiceRibSchema.nullable().optional(),
});
export type UpdateInvoiceSettingsInput = z.infer<typeof updateInvoiceSettingsSchema>;

View File

@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { INVOICE_STATUSES } from "../constants/index.js"; import { INVOICE_STATUSES, FRENCH_TVA_RATES } from "../constants/index.js";
import { invoiceThemeSlugSchema } from "./invoice-settings.js";
export const invoiceStatusSchema = z.enum(INVOICE_STATUSES); export const invoiceStatusSchema = z.enum(INVOICE_STATUSES);
@ -32,3 +33,64 @@ export const invoiceListFiltersSchema = z.object({
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>; export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>; export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>;
export type InvoiceListFiltersInput = z.infer<typeof invoiceListFiltersSchema>; export type InvoiceListFiltersInput = z.infer<typeof invoiceListFiltersSchema>;
/**
* Une ligne dans l'éditeur de facture. `totalHtCents` est recalculé côté
* serveur depuis quantity × unitPriceCents (on ne fait jamais confiance au
* client pour les totaux exigence comptable).
*/
export const invoiceLineSchema = z.object({
id: z.string().min(1, "id requis").max(64),
description: z.string().min(1, "Description requise").max(500),
quantity: z.number().positive("Quantité positive requise"),
unitPriceCents: z.number().int().min(0).max(1_000_000_00),
tvaRate: z
.number()
.refine(
(r) => (FRENCH_TVA_RATES as readonly number[]).includes(r),
`Taux de TVA invalide (valeurs autorisées : ${FRENCH_TVA_RATES.join(", ")} %)`
),
});
export type InvoiceLineInput = z.infer<typeof invoiceLineSchema>;
/**
* POST /invoices/native création d'une facture depuis l'éditeur.
*
* Différences vs createInvoiceSchema (OCR/manuel) :
* - pas de `numero` : alloué côté serveur (séquence strict)
* - pas de `amountTtcCents` : recalculé depuis lines
* - `lines` requis avec au moins 1 entrée
* - `themeSlug` + `accentColor` snapshotés
* - `clientId` obligatoire (créer le client en amont si neuf)
*
* Mode brouillon : si `draft: true`, on ne consomme PAS la séquence et
* `numero` reste "BROUILLON-<uuid>" éphémère ; status = `pending` mais
* `sequence_number` reste null. Émettre plus tard = re-POST sans draft.
*/
export const createNativeInvoiceSchema = z.object({
clientId: z.string().uuid("Client invalide"),
issueDate: z.string().datetime({ message: "Date d'émission invalide" }),
dueDate: z.string().datetime({ message: "Date d'échéance invalide" }),
paymentTermsDays: z.number().int().min(0).max(365),
planId: z.string().uuid().nullable().optional(),
themeSlug: invoiceThemeSlugSchema,
accentColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/u, "Couleur d'accent invalide (#RRGGBB)"),
lines: z.array(invoiceLineSchema).min(1, "Au moins une ligne requise"),
footerNotes: z.string().max(1000).nullable().optional(),
/** Si true → ne consomme pas le compteur, statut "draft" interne (numero éphémère). */
draft: z.boolean().optional().default(false),
});
export type CreateNativeInvoiceInput = z.infer<typeof createNativeInvoiceSchema>;
/**
* POST /invoices/preview-pdf preview d'un PDF sans persister.
* Même schéma que la création native mais sans consommer de séquence.
* Renvoie le PDF en stream (application/pdf) directement.
*/
export const previewInvoiceSchema = createNativeInvoiceSchema.omit({ draft: true });
export type PreviewInvoiceInput = z.infer<typeof previewInvoiceSchema>;

View File

@ -14,11 +14,26 @@ export type Client = {
/** Nom du contact dédié (optionnel). */ /** Nom du contact dédié (optionnel). */
contactLastName: string | null; contactLastName: string | null;
phone: string | null; phone: string | null;
/** Adresse postale (LME : requise pour mise en demeure formelle). */ /** Adresse postale legacy (texte libre, single-line). Conservé pour les
* clients importés/saisis avant l'éditeur de factures natif. Le nouveau
* code lit en priorité les champs `addressLine1` / `addressZip` / `addressCity`
* et retombe sur `address` s'ils sont vides. */
address: string | null; address: string | null;
/** SIRET de l'établissement (14 chiffres). Optionnel mais recommandé pour /** SIRET de l'établissement (14 chiffres). Optionnel mais recommandé pour
* les mises en demeure formelles et les intégrations comptables (V2). */ * les mises en demeure formelles et les intégrations comptables (V2). */
siret: string | null; siret: string | null;
/** SIREN (9 chiffres) distinct du SIRET. Ajouté avec l'éditeur natif
* pour ne pas avoir à le re-dériver à chaque rendu PDF. */
siren: string | null;
/** Numéro de TVA intracommunautaire (FR + 11 chiffres en France).
* Requis pour les factures B2B intra-UE en autoliquidation. */
tvaIntra: string | null;
addressLine1: string | null;
addressLine2: string | null;
addressZip: string | null;
addressCity: string | null;
/** ISO 3166-1 alpha-2 (ex. "FR"). Null = non renseigné. */
addressCountry: string | null;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@ -0,0 +1,83 @@
import type { InvoiceThemeSlug } from "./invoice-theme.js";
/** Identité émetteur figée dans le PDF — mentions obligatoires françaises. */
export type InvoiceIssuer = {
companyName: string | null;
addressLine1: string | null;
addressLine2: string | null;
addressZip: string | null;
addressCity: string | null;
/** ISO 3166-1 alpha-2 (ex. "FR"). */
addressCountry: string | null;
/** SIREN (9 chiffres). */
siren: string | null;
/** SIRET (14 chiffres). */
siret: string | null;
/** TVA intracommunautaire (FR + 11 chiffres en France). */
tvaIntra: string | null;
/** Ex. "RCS Paris 123 456 789". */
rcs: string | null;
/** Ex. "SARL au capital de 1 000 €". */
capital: string | null;
/** Ex. "SARL", "SAS", "EI". */
formeJuridique: string | null;
/** Code APE/NAF (5 chars, ex. "6201Z"). */
naf: string | null;
contactEmail: string | null;
contactPhone: string | null;
};
export type InvoiceRib = {
iban: string | null;
bic: string | null;
bankName: string | null;
};
/**
* Settings de facturation par organisation. Stocké JSONB dans
* `organizations.invoice_settings`. Tous les champs sont optionnels :
* l'org peut être en cours de paramétrage.
*
* La résolution effective (avec defaults) se fait côté API via
* `resolveInvoiceSettings(org)` (cf. apps/api/app/services/invoice_settings.ts).
*/
export type InvoiceSettings = {
themeSlug?: InvoiceThemeSlug;
/** Hex #RRGGBB. Défaut = brand.primaryColor ou rubis #9F1239. */
accentColor?: string | null;
/** Préfixe du numéro de facture (ex. "FAC-2026-"). */
numeroPrefix?: string | null;
/** Prochain numéro de séquence à allouer (ex. 42 → "FAC-2026-0042"). */
numeroNextSeq?: number | null;
/** Padding (zéros de tête) du compteur. Défaut = 4. */
numeroPadding?: number | null;
/** Délai de paiement par défaut en jours. */
paymentTermsDays?: number | null;
/** Texte de la mention pénalités de retard (art. L441-10). */
penaltyRateText?: string | null;
/** Texte de la mention escompte (art. L441-9). */
escompteText?: string | null;
/** Texte additionnel en pied de page (libre). */
footerLegalText?: string | null;
issuer?: Partial<InvoiceIssuer> | null;
rib?: Partial<InvoiceRib> | null;
};
/**
* Settings résolus avec les defaults ce que les templates consomment.
* Champs scalaires garantis non-undefined (peuvent être null ou ""), structures
* imbriquées toujours présentes en `Partial`.
*/
export type ResolvedInvoiceSettings = {
themeSlug: InvoiceThemeSlug;
accentColor: string;
numeroPrefix: string;
numeroNextSeq: number;
numeroPadding: number;
paymentTermsDays: number;
penaltyRateText: string;
escompteText: string;
footerLegalText: string;
issuer: InvoiceIssuer;
rib: InvoiceRib;
};

View File

@ -0,0 +1,15 @@
import type { INVOICE_THEME_SLUGS } from "../constants/index.js";
/** Slug d'un thème de facture — l'un des 4 templates pré-faits. */
export type InvoiceThemeSlug = (typeof INVOICE_THEME_SLUGS)[number];
/**
* Métadonnées d'un thème affichées dans la galerie de sélection
* (/parametres/facturation). Le rendu lui-même vit dans
* packages/ui/invoice-templates/<slug>.tsx.
*/
export type InvoiceTheme = {
slug: InvoiceThemeSlug;
name: string;
description: string;
};

View File

@ -1,15 +1,86 @@
import type { INVOICE_STATUSES } from "../constants/index.js"; import type { INVOICE_STATUSES } from "../constants/index.js";
import type { InvoiceThemeSlug } from "./invoice-theme.js";
import type { InvoiceIssuer } from "./invoice-settings.js";
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]; export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
/**
* Une ligne de facture (éditeur natif uniquement). Pour les factures
* importées par OCR, ce champ reste null et le montant est traité comme
* un total opaque.
*/
export type InvoiceLine = {
/** UUID stable pour le diff côté UI (drag-and-drop ordering). */
id: string;
description: string;
/** Quantité, en unités atomiques (1 = une unité, 0.5 autorisé pour heures). */
quantity: number;
/** Prix unitaire HT en centimes. */
unitPriceCents: number;
/** Taux de TVA en pourcent (0, 2.1, 5.5, 10, 20 en France). */
tvaRate: number;
/** Total HT de la ligne en centimes = quantity × unitPriceCents (arrondi entier). */
totalHtCents: number;
};
/** Ventilation TVA par taux — obligatoire si plusieurs taux sur une facture. */
export type TvaBreakdownItem = {
rate: number;
htCents: number;
tvaCents: number;
};
/**
* Snapshot du client figé au moment de l'émission. Permet à la facture de
* rester intacte même si le client change d'adresse ou de raison sociale
* plus tard.
*/
export type ClientSnapshot = {
name: string;
email: string;
contactFirstName: string | null;
contactLastName: string | null;
phone: string | null;
siret: string | null;
siren: string | null;
tvaIntra: string | null;
addressLine1: string | null;
addressLine2: string | null;
addressZip: string | null;
addressCity: string | null;
addressCountry: string | null;
};
export type Invoice = { export type Invoice = {
id: string; id: string;
organizationId: string; organizationId: string;
clientId: string; clientId: string;
/** Numéro de facture tel qu'émis par l'utilisateur (peut contenir des lettres). */ /** Numéro de facture tel qu'émis (préfixe + séquence ou saisi manuellement). */
numero: string; numero: string;
/** Index de séquence strict (null pour les factures importées OCR/manuelles). */
sequenceNumber: number | null;
/** Montant TTC en centimes (toujours int, jamais float). */ /** Montant TTC en centimes (toujours int, jamais float). */
amountTtcCents: number; amountTtcCents: number;
/** Montant HT en centimes (null pour les factures OCR sans détail). */
amountHtCents: number | null;
/** Montant TVA en centimes (null pour les factures OCR sans détail). */
amountTvaCents: number | null;
/** Ventilation TVA par taux (null pour les factures OCR). */
tvaBreakdown: TvaBreakdownItem[] | null;
/** Lignes (null pour les factures importées OCR sans extraction de lignes). */
lines: InvoiceLine[] | null;
/** Délai de paiement en jours (snapshot à l'émission). */
paymentTermsDays: number | null;
/** Snapshot du client à l'émission (null pour les factures OCR pré-feature). */
clientSnapshot: ClientSnapshot | null;
/** Snapshot de l'émetteur à l'émission (null pour les factures OCR pré-feature). */
issuerSnapshot: InvoiceIssuer | null;
/** Thème utilisé pour le rendu PDF (null pour les factures OCR). */
themeSlug: InvoiceThemeSlug | null;
/** Couleur d'accent snapshot (hex #RRGGBB). */
themeAccentColor: string | null;
/** Notes affichées en pied de page (footer de la facture). */
footerNotes: string | null;
/** Date d'émission, ISO 8601. */ /** Date d'émission, ISO 8601. */
issueDate: string; issueDate: string;
/** Date d'échéance, ISO 8601. */ /** Date d'échéance, ISO 8601. */
@ -17,8 +88,12 @@ export type Invoice = {
status: InvoiceStatus; status: InvoiceStatus;
/** Plan de relance associé (null si la facture n'est pas encore programmée). */ /** Plan de relance associé (null si la facture n'est pas encore programmée). */
planId: string | null; planId: string | null;
/** Clé MinIO du PDF source, si uploadé. */ /** Clé MinIO du PDF (généré pour les natives, uploadé pour les OCR). */
pdfStorageKey: string | null; pdfStorageKey: string | null;
/** Timestamp de la dernière génération de PDF (null si jamais généré). */
pdfGeneratedAt: string | null;
/** true = créée dans l'éditeur Rubis ; false = uploadée (OCR/manuel). */
isNative: boolean;
notes: string | null; notes: string | null;
/** Combien de rubis cette facture a fait gagner (calculé côté API). */ /** Combien de rubis cette facture a fait gagner (calculé côté API). */
rubisEarned: number; rubisEarned: number;