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

68 lines
2.9 KiB
TypeScript

import vine from '@vinejs/vine'
const name = () => vine.string().minLength(2).maxLength(120)
const email = () => vine.string().email().maxLength(254)
// 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.
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(),
email: email(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
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(),
})
/**
* Validator pour PATCH /clients/:id. Tous les champs optionnels.
*/
export const updateClientValidator = vine.create({
name: name().optional(),
email: email().optional(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
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(),
})