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>
This commit is contained in:
ordinarthur 2026-05-14 02:07:45 +02:00
parent 1200c549a0
commit e0b47ddfdc
32 changed files with 2043 additions and 14 deletions

View File

@ -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,
})

View File

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

View File

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

View File

@ -1,8 +1,15 @@
import Invoice from '#models/invoice'
import 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'
@ -443,4 +454,204 @@ 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 (stub Phase 1 → null, vraie impl Phase 2).
try {
const resolvedSettings = resolveInvoiceSettings(
(await Organization.find(organizationId))!
)
const generated = await generateInvoicePdf({ invoice, resolvedSettings })
if (generated) {
invoice.pdfStorageKey = generated.storageKey
invoice.pdfGeneratedAt = DateTime.utc()
await invoice.save()
}
} catch (err) {
// PDF generation échouée n'invalide pas la facture : elle est créée,
// le PDF sera regénérable plus tard. Log + continue.
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>` ou déclencher un download
* "voir le PDF avant émission".
*
* Phase 1 stub 501. Phase 2 active la vraie génération.
*/
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)
// Construit un Invoice "virtuel" non-persisté pour le rendu.
const org = await Organization.findOrFail(organizationId)
const resolvedSettings = resolveInvoiceSettings(org)
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
const pdf = await previewInvoicePdf({ invoice: virtualInvoice, resolvedSettings })
response.header('Content-Type', 'application/pdf')
response.header('Cache-Control', 'no-store')
return response.send(pdf)
}
}

View File

@ -1,10 +1,41 @@
import { ClientSchema } from '#database/schema'
import { 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>

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -0,0 +1,58 @@
/**
* invoice_pdf génération de PDF pour les factures natives.
*
* **Phase 1 stub.** L'implémentation réelle (templates @react-pdf/renderer
* + upload MinIO) arrive en Phase 2 avec packages/ui/invoice-templates/.
* Pour l'instant, `generateInvoicePdf` renvoie `null` (= pas de PDF stocké)
* et `previewInvoicePdf` throw `not_implemented` (501).
*
* Le contrat de l'interface est figé pour que la Phase 2 soit un drop-in
* remplacement sans toucher au controller : on remplace le corps de ces
* fonctions par l'appel à `@react-pdf/renderer.renderToBuffer(...)` puis
* `media_storage.uploadBuffer(...)`.
*/
import type Invoice from '#models/invoice'
import { Exception } from '@adonisjs/core/exceptions'
export interface InvoiceRenderContext {
/** L'invoice complet, snapshots compris. */
invoice: Invoice
/** Settings résolus (themeSlug, accentColor, issuer, rib…). */
// Le type complet est dans #services/invoice_settings → ResolvedInvoiceSettings,
// mais comme c'est un stub on garde unknown pour ne pas créer de couplage
// que Phase 2 devra de toute façon retravailler.
resolvedSettings: unknown
}
export interface GeneratedPdf {
/** Clé MinIO sous laquelle le PDF est stocké. */
storageKey: string
/** Taille du PDF en bytes. */
bytes: number
}
/**
* Génère le PDF de la facture et l'upload sur MinIO. Stub Phase 1.
*
* Phase 2 : renderToBuffer(<Theme {...props} />) uploadBuffer storageKey.
*/
export async function generateInvoicePdf(_ctx: InvoiceRenderContext): Promise<GeneratedPdf | null> {
// Phase 1 : pas de génération. Le controller persiste l'invoice avec
// pdfStorageKey=null et l'UI affichera "PDF en cours de génération"
// (ou un placeholder). La Phase 2 active la vraie génération.
return null
}
/**
* Renvoie un buffer PDF pour preview (sans persister). Stub Phase 1.
*
* Phase 2 : même rendu que generateInvoicePdf, mais retourne le buffer
* directement au lieu d'uploader.
*/
export async function previewInvoicePdf(_ctx: InvoiceRenderContext): Promise<Buffer> {
throw new Exception('PDF preview not yet implemented (Phase 2)', {
status: 501,
code: 'not_implemented',
})
}

View File

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

View File

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

View File

@ -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()!,

View File

@ -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,

View File

@ -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(),
})

View File

@ -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(),
})
)

View File

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

View File

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

View File

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

View File

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

View File

@ -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
@ -376,6 +407,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

View File

@ -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,

View File

@ -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 });

View File

@ -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,
}));

View File

@ -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;

View File

@ -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

View File

@ -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(),
});

View File

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

View File

@ -1,5 +1,6 @@
import { z } from "zod";
import { 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>;

View File

@ -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;

View File

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

View File

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

View File

@ -1,15 +1,86 @@
import type { INVOICE_STATUSES } from "../constants/index.js";
import type { 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;