ordinarthur e0b47ddfdc feat(invoices): éditeur de factures natif — data model + API (Phase 1)
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.

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

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

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

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

96 lines
3.5 KiB
TypeScript

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