Compare commits
6 Commits
1200c549a0
...
b81bc2609b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b81bc2609b | ||
|
|
e449b708f3 | ||
|
|
aa6468e9a0 | ||
|
|
0680bb9f77 | ||
|
|
ab07cd4a3b | ||
|
|
e0b47ddfdc |
17
CLAUDE.md
17
CLAUDE.md
@ -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é.
|
||||
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.
|
||||
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").
|
||||
- **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.
|
||||
- **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.
|
||||
- **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)
|
||||
- Upload drag-and-drop + OCR factures (PDF, PNG, JPG)
|
||||
- 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*)
|
||||
- Éditeur de plan (cadence + templates email avec variables)
|
||||
- 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)
|
||||
- SMS et multi-users : V2 + plans payants seulement
|
||||
- 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
|
||||
|
||||
@ -123,6 +130,7 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
|
||||
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
|
||||
| OCR provider | à benchmarker | ADR-020 (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`.
|
||||
|
||||
@ -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/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
|
||||
| `/packages/ui/` | Design system partagé (tokens Tailwind v4 + composants TSX) |
|
||||
| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing) |
|
||||
| `/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/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. 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/decisions.md` | Log de décisions avec rationale (format ADR-light) |
|
||||
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
|
||||
|
||||
@ -176,6 +176,17 @@ export default class ClientsController {
|
||||
phone: payload.phone ?? null,
|
||||
address: payload.address ?? 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,
|
||||
})
|
||||
|
||||
|
||||
81
apps/api/app/controllers/invoice_settings_controller.ts
Normal file
81
apps/api/app/controllers/invoice_settings_controller.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
50
apps/api/app/controllers/invoice_themes_controller.ts
Normal file
50
apps/api/app/controllers/invoice_themes_controller.ts
Normal 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 l’artisanat.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export default class InvoiceThemesController {
|
||||
async index({ response }: HttpContext) {
|
||||
return response.json({ data: INVOICE_THEMES })
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,15 @@
|
||||
import Invoice from '#models/invoice'
|
||||
import Client from '#models/client'
|
||||
import Organization from '#models/organization'
|
||||
import Plan from '#models/plan'
|
||||
import RelanceTask from '#models/relance_task'
|
||||
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 { Exception } from '@adonisjs/core/exceptions'
|
||||
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 { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
|
||||
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 * as clock from '#services/clock'
|
||||
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
|
||||
* validation. 404 si la facture n'a pas de fichier (saisie manuelle).
|
||||
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob
|
||||
* puis affiche dans un <iframe>/<img> via objectURL.
|
||||
* Cas couverts :
|
||||
* - Facture OCR/manuelle (`pdfStorageKey` propagé du draft) → stream tel quel.
|
||||
* - Facture native déjà rendue (`pdfStorageKey` non-null) → stream depuis MinIO.
|
||||
* - 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) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
@ -359,6 +375,29 @@ export default class InvoicesController {
|
||||
if (!invoice) {
|
||||
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) {
|
||||
throw new Exception('Aucun PDF stocké pour cette facture', {
|
||||
status: 404,
|
||||
@ -443,4 +482,227 @@ export default class InvoicesController {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,41 @@
|
||||
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 Organization from '#models/organization'
|
||||
import Invoice from '#models/invoice'
|
||||
|
||||
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)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
|
||||
@ -1,11 +1,84 @@
|
||||
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 { DateTime } from 'luxon'
|
||||
import Organization from '#models/organization'
|
||||
import Client from '#models/client'
|
||||
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 {
|
||||
/**
|
||||
* 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)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import User from '#models/user'
|
||||
import BankConnection from '#models/bank_connection'
|
||||
import type { BrandSettings } from '#services/brand'
|
||||
import type { InvoiceSettings } from '#services/invoice_settings'
|
||||
|
||||
export default class Organization extends OrganizationSchema {
|
||||
/**
|
||||
@ -16,6 +17,16 @@ export default class Organization extends OrganizationSchema {
|
||||
@column()
|
||||
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)
|
||||
declare users: HasMany<typeof User>
|
||||
|
||||
|
||||
289
apps/api/app/pdf-templates/classique.tsx
Normal file
289
apps/api/app/pdf-templates/classique.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
apps/api/app/pdf-templates/common.tsx
Normal file
148
apps/api/app/pdf-templates/common.tsx
Normal 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 }
|
||||
286
apps/api/app/pdf-templates/elegant.tsx
Normal file
286
apps/api/app/pdf-templates/elegant.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
apps/api/app/pdf-templates/index.tsx
Normal file
51
apps/api/app/pdf-templates/index.tsx
Normal 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 }
|
||||
230
apps/api/app/pdf-templates/minimal.tsx
Normal file
230
apps/api/app/pdf-templates/minimal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
apps/api/app/pdf-templates/moderne.tsx
Normal file
269
apps/api/app/pdf-templates/moderne.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
apps/api/app/services/invoice_numbering.ts
Normal file
98
apps/api/app/services/invoice_numbering.ts
Normal 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 }
|
||||
}
|
||||
110
apps/api/app/services/invoice_pdf.ts
Normal file
110
apps/api/app/services/invoice_pdf.ts
Normal 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)
|
||||
}
|
||||
274
apps/api/app/services/invoice_settings.ts
Normal file
274
apps/api/app/services/invoice_settings.ts
Normal 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()
|
||||
}
|
||||
87
apps/api/app/services/invoice_totals.ts
Normal file
87
apps/api/app/services/invoice_totals.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ import path from 'node:path'
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||
|
||||
export type MediaScope = 'blog' | 'brand-logo'
|
||||
export type MediaScope = 'blog' | 'brand-logo' | 'invoice-pdf'
|
||||
|
||||
interface ScopeConfig {
|
||||
/** Préfixe de stockage MinIO (clé S3). */
|
||||
@ -62,6 +62,16 @@ const SCOPES: Record<MediaScope, ScopeConfig> = {
|
||||
allowedExts: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||
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 {
|
||||
@ -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 {
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
@ -168,6 +222,8 @@ function extToContentType(ext: string): string {
|
||||
return 'image/webp'
|
||||
case 'svg':
|
||||
return 'image/svg+xml'
|
||||
case 'pdf':
|
||||
return 'application/pdf'
|
||||
default:
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
@ -4,6 +4,17 @@ import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
export default class ClientTransformer extends BaseTransformer<Client> {
|
||||
toObject() {
|
||||
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 {
|
||||
id: c.id,
|
||||
organizationId: c.organizationId,
|
||||
@ -14,6 +25,13 @@ export default class ClientTransformer extends BaseTransformer<Client> {
|
||||
phone: c.phone,
|
||||
address: c.address,
|
||||
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,
|
||||
createdAt: c.createdAt.toISO()!,
|
||||
updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!,
|
||||
|
||||
@ -4,6 +4,25 @@ import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
export default class InvoiceTransformer extends BaseTransformer<Invoice> {
|
||||
toObject() {
|
||||
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 {
|
||||
id: i.id,
|
||||
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.
|
||||
clientName: i.client?.name ?? '',
|
||||
numero: i.numero,
|
||||
sequenceNumber: native.sequenceNumber ?? null,
|
||||
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()!,
|
||||
dueDate: i.dueDate.toISO()!,
|
||||
status: i.status,
|
||||
planId: i.planId,
|
||||
planName: i.plan?.name ?? null,
|
||||
pdfStorageKey: i.pdfStorageKey,
|
||||
pdfGeneratedAt: native.pdfGeneratedAt?.toISO() ?? null,
|
||||
notes: i.notes,
|
||||
rubisEarned: i.rubisEarned,
|
||||
paidAt: i.paidAt?.toISO() ?? null,
|
||||
|
||||
@ -2,10 +2,18 @@ import vine from '@vinejs/vine'
|
||||
|
||||
const name = () => vine.string().minLength(2).maxLength(120)
|
||||
const email = () => vine.string().email().maxLength(254)
|
||||
// SIRET = 14 chiffres exactement (cf. INSEE).
|
||||
// SIRET = 14 chiffres, SIREN = 9 chiffres (cf. INSEE).
|
||||
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 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)
|
||||
// Prénom/nom du contact dédié — utilisés comme variables dans les templates
|
||||
// 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
|
||||
* 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({
|
||||
name: name(),
|
||||
@ -23,6 +35,13 @@ export const createClientValidator = vine.create({
|
||||
phone: phone().nullable().optional(),
|
||||
address: address().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(),
|
||||
})
|
||||
|
||||
@ -37,5 +56,12 @@ export const updateClientValidator = vine.create({
|
||||
phone: phone().nullable().optional(),
|
||||
address: address().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(),
|
||||
})
|
||||
|
||||
@ -40,3 +40,56 @@ export const createInvoiceValidator = vine.create({
|
||||
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
|
||||
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(),
|
||||
})
|
||||
)
|
||||
|
||||
56
apps/api/app/validators/invoice_settings.ts
Normal file
56
apps/api/app/validators/invoice_settings.ts
Normal 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(),
|
||||
})
|
||||
)
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 ré-é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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@
|
||||
"#exceptions/*": "./app/exceptions/*.js",
|
||||
"#models/*": "./app/models/*.js",
|
||||
"#mails/*": "./app/mails/*.js",
|
||||
"#pdf-templates/*": "./app/pdf-templates/*.js",
|
||||
"#services/*": "./app/services/*.js",
|
||||
"#jobs/*": "./app/jobs/*.js",
|
||||
"#listeners/*": "./app/listeners/*.js",
|
||||
|
||||
@ -23,6 +23,8 @@ const BlogUploadsController = () => import('#controllers/blog_uploads_controller
|
||||
const BrandController = () => import('#controllers/brand_controller')
|
||||
const BankingController = () => import('#controllers/banking_controller')
|
||||
const WebhooksPowensController = () => import('#controllers/webhooks_powens_controller')
|
||||
const InvoiceSettingsController = () => import('#controllers/invoice_settings_controller')
|
||||
const InvoiceThemesController = () => import('#controllers/invoice_themes_controller')
|
||||
|
||||
|
||||
router
|
||||
@ -205,11 +207,40 @@ router
|
||||
.group(() => {
|
||||
router.get('me', [controllers.Organizations, 'show']).as('show')
|
||||
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')
|
||||
.as('organizations')
|
||||
.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
|
||||
* `assertBusinessPlan` throw 403 `business_plan_required` que le SPA
|
||||
@ -334,20 +365,36 @@ router
|
||||
.use(middleware.auth())
|
||||
|
||||
/**
|
||||
* Demo — auth requise. Mode démo opt-in par org (cf. CLAUDE.md →
|
||||
* Architecture). Routes opérantes seulement si `org.demo_mode = true`.
|
||||
* Demo — réservé aux admins Rubis (outil de prospection commerciale).
|
||||
* 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
|
||||
.group(() => {
|
||||
router.post('start', [controllers.Demo, 'start']).as('start')
|
||||
router.post('end', [controllers.Demo, 'end']).as('end')
|
||||
router.post('tick', [controllers.Demo, 'tick']).as('tick')
|
||||
router.get('state', [controllers.Demo, 'state']).as('state')
|
||||
router.get('inbox', [controllers.Demo, 'inbox']).as('inbox')
|
||||
})
|
||||
.prefix('demo')
|
||||
.as('demo')
|
||||
.use(middleware.auth())
|
||||
.as('demo.admin')
|
||||
.use([middleware.auth(), middleware.admin()])
|
||||
|
||||
/**
|
||||
* 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.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)
|
||||
router.post('upload', [controllers.ImportBatches, 'upload']).as('upload')
|
||||
router
|
||||
|
||||
@ -81,16 +81,20 @@ export function BankingSection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [callbackStatus, callbackReason]);
|
||||
|
||||
if (!isPaid) {
|
||||
return <UpsellCard />;
|
||||
}
|
||||
|
||||
// Banking pas encore activé mais teaser ON → afficher "Bientôt disponible"
|
||||
// pour annoncer la feature aux Pro/Business pendant la fenêtre KYC Powens.
|
||||
// Order matters : `comingSoon` doit gagner sur `isPaid`. La feature n'est
|
||||
// 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
|
||||
// feature à venir à tout le monde ; l'upsell reviendra automatiquement
|
||||
// une fois `BANKING_ENABLED=true` en prod.
|
||||
if (status?.comingSoon) {
|
||||
return <ComingSoonCard />;
|
||||
}
|
||||
|
||||
if (!isPaid) {
|
||||
return <UpsellCard />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BankingPaidView
|
||||
isLoading={connectionsQuery.isLoading}
|
||||
|
||||
@ -231,4 +231,45 @@ export const api = {
|
||||
const blob = await res.blob();
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
60
apps/web/src/lib/invoice-settings.ts
Normal file
60
apps/web/src/lib/invoice-settings.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateNativeInvoiceInput, Invoice, PreviewInvoiceInput } from "@rubis/shared";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export type ImportBatchResponse = {
|
||||
@ -12,3 +15,38 @@ export function uploadInvoiceFiles(files: File[]): Promise<ImportBatchResponse>
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@ -240,11 +240,46 @@ export const mockDb = {
|
||||
},
|
||||
createClient(
|
||||
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 {
|
||||
const db = load();
|
||||
const now = new Date().toISOString();
|
||||
const client: Client = {
|
||||
siren: null,
|
||||
tvaIntra: null,
|
||||
addressLine1: null,
|
||||
addressLine2: null,
|
||||
addressZip: null,
|
||||
addressCity: null,
|
||||
addressCountry: null,
|
||||
...input,
|
||||
id: `cli_${crypto.randomUUID()}`,
|
||||
organizationId: orgId,
|
||||
@ -332,10 +367,66 @@ export const mockDb = {
|
||||
listInvoicesForOrg(orgId: string): StoredInvoice[] {
|
||||
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 now = new Date().toISOString();
|
||||
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,
|
||||
id: `inv_${crypto.randomUUID()}`,
|
||||
organizationId: orgId,
|
||||
|
||||
@ -102,6 +102,28 @@ const createClientSchema = z.object({
|
||||
.nullable()
|
||||
.optional()
|
||||
.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),
|
||||
});
|
||||
|
||||
@ -201,6 +223,13 @@ export const clientHandlers = [
|
||||
phone: parsed.data.phone,
|
||||
address: parsed.data.address,
|
||||
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,
|
||||
});
|
||||
return HttpResponse.json({ data: created }, { status: 201 });
|
||||
|
||||
@ -18,7 +18,40 @@ function isoFromOffset(daysOffset: number, hour = 9): string {
|
||||
|
||||
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",
|
||||
organizationId: ORG,
|
||||
@ -89,7 +122,10 @@ export const SEED_CLIENTS: Client[] = [
|
||||
createdAt: isoFromOffset(-30),
|
||||
updatedAt: isoFromOffset(-1),
|
||||
},
|
||||
];
|
||||
] satisfies Omit<Client, keyof typeof CLIENT_INVOICING_DEFAULTS>[]).map((c) => ({
|
||||
...CLIENT_INVOICING_DEFAULTS,
|
||||
...c,
|
||||
}));
|
||||
|
||||
export const SEED_PLANS: Plan[] = [
|
||||
{
|
||||
@ -255,7 +291,7 @@ export const SEED_PLANS: Plan[] = [
|
||||
|
||||
type SeedInvoice = Invoice & { clientName: string; planName: string | null; statusLabel?: string };
|
||||
|
||||
export const SEED_INVOICES: SeedInvoice[] = [
|
||||
export const SEED_INVOICES: SeedInvoice[] = ([
|
||||
// À relancer (échéance future)
|
||||
{
|
||||
id: "inv_001",
|
||||
@ -445,4 +481,7 @@ export const SEED_INVOICES: SeedInvoice[] = [
|
||||
createdAt: isoFromOffset(-100),
|
||||
updatedAt: isoFromOffset(-15),
|
||||
},
|
||||
];
|
||||
] satisfies Omit<SeedInvoice, keyof typeof INVOICE_NATIVE_DEFAULTS>[]).map((i) => ({
|
||||
...INVOICE_NATIVE_DEFAULTS,
|
||||
...i,
|
||||
}));
|
||||
|
||||
@ -188,13 +188,21 @@ function FacturesPage() {
|
||||
pour voir la timeline.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" asChild className="shrink-0">
|
||||
<Link to="/factures/import">
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Nouvelle facture</span>
|
||||
<span className="sm:hidden">Nouvelle</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link to="/factures/import">
|
||||
<span className="hidden sm:inline">Importer</span>
|
||||
<span className="sm:hidden">Import</span>
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
<PlanLimitBanner />
|
||||
|
||||
599
apps/web/src/routes/_app/factures_.nouvelle.tsx
Normal file
599
apps/web/src/routes/_app/factures_.nouvelle.tsx
Normal 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;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
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 { SettingsSection } from "@/components/settings/SettingsSection";
|
||||
@ -13,6 +13,7 @@ import { Button } from "@rubis/ui";
|
||||
import { Card } from "@rubis/ui";
|
||||
import { useSubscription } from "@/lib/billing";
|
||||
import { useBankingStatus } from "@/lib/banking";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Search params optionnels :
|
||||
@ -52,6 +53,12 @@ function ParametresPage() {
|
||||
const { data: bankingStatus } = useBankingStatus();
|
||||
const showBanking =
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
@ -126,6 +133,34 @@ function ParametresPage() {
|
||||
</Card>
|
||||
</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
|
||||
eyebrow="Marque"
|
||||
title={
|
||||
@ -160,11 +195,21 @@ function ParametresPage() {
|
||||
<SettingsSection
|
||||
eyebrow="Banque"
|
||||
title={
|
||||
<>
|
||||
Connecter votre <em className="text-rubis">banque</em>
|
||||
</>
|
||||
bankingStatus?.comingSoon ? (
|
||||
<>
|
||||
Bientôt : votre <em className="text-rubis">banque</em> connectée à Rubis
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
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
|
||||
callbackStatus={search.banking}
|
||||
@ -173,17 +218,19 @@ function ParametresPage() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
eyebrow="Démonstration"
|
||||
title={
|
||||
<>
|
||||
Faire vivre Rubis en <em className="text-rubis">accéléré</em>
|
||||
</>
|
||||
}
|
||||
description="Mode démo : horloge virtuelle qui avance dans le temps, emails capturés au lieu d'être envoyés à de vrais clients. Idéal pour montrer Rubis à un prospect."
|
||||
>
|
||||
<DemoToggle />
|
||||
</SettingsSection>
|
||||
{isAdmin && (
|
||||
<SettingsSection
|
||||
eyebrow="Démonstration"
|
||||
title={
|
||||
<>
|
||||
Faire vivre Rubis en <em className="text-rubis">accéléré</em>
|
||||
</>
|
||||
}
|
||||
description="Mode démo : horloge virtuelle qui avance dans le temps, emails capturés au lieu d'être envoyés à de vrais clients. Idéal pour montrer Rubis à un prospect."
|
||||
>
|
||||
<DemoToggle />
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
eyebrow="Zone danger"
|
||||
|
||||
766
apps/web/src/routes/_app/parametres_.facturation.tsx
Normal file
766
apps/web/src/routes/_app/parametres_.facturation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
| # | Sujet | Pourquoi en attente |
|
||||
|
||||
59
docs/flow.md
59
docs/flow.md
@ -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)
|
||||
|
||||
| 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. |
|
||||
| Relancer par SMS | V2 (réservé plan le plus cher). |
|
||||
| Multi-utilisateurs | V2 (plans payants seulement). |
|
||||
| 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. |
|
||||
| É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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
- **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
|
||||
|
||||
- **Bibliothèque** : 4 plans pré-fournis par défaut (cf. `apps/api/app/services/default_plans.ts`)
|
||||
|
||||
@ -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/limiter`** — rate limiting sur les routes publiques (login, signup)
|
||||
- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads
|
||||
- **`@react-pdf/renderer`** (ADR-025) — génération des PDF de factures natives côté serveur. 4 templates TSX dans `apps/api/app/pdf-templates/` (`classique.tsx`, `moderne.tsx`, `minimal.tsx`, `elegant.tsx`) + `common.tsx` (formatters fr-FR, palette) + `index.tsx` (dispatcher slug → composant). L'import alias `#pdf-templates/*` est déclaré dans `package.json`.
|
||||
|
||||
### Conventions de routes
|
||||
Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1.
|
||||
@ -152,13 +153,18 @@ GET /api/v1/organizations/:id
|
||||
|
||||
GET /api/v1/invoices
|
||||
POST /api/v1/invoices # create manual
|
||||
POST /api/v1/invoices/native # editor natif (ADR-025)
|
||||
POST /api/v1/invoices/preview-pdf # preview PDF stream (debounced)
|
||||
POST /api/v1/invoices/upload # OCR pipeline
|
||||
GET /api/v1/invoices/:id
|
||||
PATCH /api/v1/invoices/:id
|
||||
DELETE /api/v1/invoices/:id
|
||||
GET /api/v1/invoices/:id/pdf # PDF natif (lazy regenerate si manquant)
|
||||
POST /api/v1/invoices/:id/relance # relance manuelle
|
||||
POST /api/v1/invoices/:id/mark-paid
|
||||
|
||||
GET /api/v1/invoice-themes # liste des 4 thèmes (ADR-025)
|
||||
|
||||
GET /api/v1/plans
|
||||
POST /api/v1/plans
|
||||
PATCH /api/v1/plans/:id
|
||||
@ -168,6 +174,9 @@ GET /api/v1/clients
|
||||
POST /api/v1/clients
|
||||
PATCH /api/v1/clients/:id
|
||||
|
||||
GET /api/v1/organizations/me/invoice-settings # ADR-025
|
||||
PATCH /api/v1/organizations/me/invoice-settings
|
||||
|
||||
GET /api/v1/dashboard/kpis
|
||||
GET /api/v1/dashboard/activity
|
||||
```
|
||||
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
```
|
||||
|
||||
@ -44,3 +44,69 @@ export const ACCEPTED_INVOICE_MIME_TYPES = [
|
||||
] as const;
|
||||
|
||||
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;
|
||||
|
||||
@ -3,12 +3,15 @@ export * from "./types/auth.js";
|
||||
export * from "./types/user.js";
|
||||
export * from "./types/client.js";
|
||||
export * from "./types/invoice.js";
|
||||
export * from "./types/invoice-settings.js";
|
||||
export * from "./types/invoice-theme.js";
|
||||
export * from "./types/plan.js";
|
||||
|
||||
// Schemas
|
||||
export * from "./schemas/auth.js";
|
||||
export * from "./schemas/client.js";
|
||||
export * from "./schemas/invoice.js";
|
||||
export * from "./schemas/invoice-settings.js";
|
||||
export * from "./schemas/plan.js";
|
||||
|
||||
// Constants
|
||||
|
||||
@ -12,6 +12,27 @@ export const createClientSchema = z.object({
|
||||
.regex(/^\d{14}$/u, "Le SIRET doit contenir 14 chiffres")
|
||||
.nullable()
|
||||
.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(),
|
||||
});
|
||||
|
||||
|
||||
85
packages/shared/src/schemas/invoice-settings.ts
Normal file
85
packages/shared/src/schemas/invoice-settings.ts
Normal 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>;
|
||||
@ -1,5 +1,6 @@
|
||||
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);
|
||||
|
||||
@ -32,3 +33,64 @@ export const invoiceListFiltersSchema = z.object({
|
||||
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
|
||||
export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>;
|
||||
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>;
|
||||
|
||||
@ -14,11 +14,26 @@ export type Client = {
|
||||
/** Nom du contact dédié (optionnel). */
|
||||
contactLastName: 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;
|
||||
/** SIRET de l'établissement (14 chiffres). Optionnel mais recommandé pour
|
||||
* les mises en demeure formelles et les intégrations comptables (V2). */
|
||||
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;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
83
packages/shared/src/types/invoice-settings.ts
Normal file
83
packages/shared/src/types/invoice-settings.ts
Normal 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;
|
||||
};
|
||||
15
packages/shared/src/types/invoice-theme.ts
Normal file
15
packages/shared/src/types/invoice-theme.ts
Normal 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;
|
||||
};
|
||||
@ -1,15 +1,86 @@
|
||||
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];
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
id: string;
|
||||
organizationId: 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;
|
||||
/** Index de séquence strict (null pour les factures importées OCR/manuelles). */
|
||||
sequenceNumber: number | null;
|
||||
/** Montant TTC en centimes (toujours int, jamais float). */
|
||||
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. */
|
||||
issueDate: string;
|
||||
/** Date d'échéance, ISO 8601. */
|
||||
@ -17,8 +88,12 @@ export type Invoice = {
|
||||
status: InvoiceStatus;
|
||||
/** Plan de relance associé (null si la facture n'est pas encore programmée). */
|
||||
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;
|
||||
/** 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;
|
||||
/** Combien de rubis cette facture a fait gagner (calculé côté API). */
|
||||
rubisEarned: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user