rubis/apps/api/app/controllers/invoices_controller.ts
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

658 lines
23 KiB
TypeScript

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,
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'
import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
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'
const PAGE_SIZE = 50
// Priorité d'affichage côté liste : ce qui est actionnable d'abord.
const STATUS_PRIORITY: Record<string, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
}
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
}
function serializeInvoice(i: Invoice) {
return new InvoiceTransformer(i).toObject()
}
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant (V1 simplifié — les RelanceTask viendront plus tard).
*
* - étapes dont sendDay <= aujourd'hui : 'past' (envoyées)
* - étape actuelle (la prochaine future) : 'current'
* - étapes futures : 'future'
*/
function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = [],
// `now` injecté par le caller — orgs en mode démo lisent depuis virtualNow.
now: DateTime = DateTime.utc()
): Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> {
const events: Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> = [
{
id: `${invoice.id}__issued`,
state: 'past',
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
what: 'Importée',
},
]
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
const dueMs = invoice.dueDate.toMillis()
const nowMs = now.toMillis()
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
let currentSet = false
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
const task = taskByStepId.get(step.id)
const stepDate = task?.sentAt ?? task?.sendAt ?? DateTime.fromMillis(sendMs)
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
let state: 'past' | 'current' | 'future'
if (task?.status === 'sent') state = 'past'
else if (task?.status === 'scheduled' && task.sendAt.toMillis() < nowMs) state = 'current'
else if (!task && invoice.status === 'pending' && !currentSet) {
state = 'current'
currentSet = true
} else if (!currentSet) {
state = 'current'
currentSet = true
} else state = 'future'
const subject = step.subject.replace('{{numero}}', invoice.numero)
// Wording uniforme et rassurant : aucune relance ne part sans que l'user
// confirme l'impayé. On évite "programmé" tout court qui sonne comme
// "ça va partir tout seul".
const what = task
? task.status === 'sent'
? `Envoyée après votre confirmation · "${subject}"`
: task.status === 'cancelled'
? `Annulée — facture encaissée · "${subject}"`
: `Confirmation avant envoi · "${subject}"`
: `Confirmation avant envoi · "${subject}"`
events.push({
id: `${invoice.id}__step_${step.order}`,
state,
when: `${formatShortDate(stepDate)} · ${labelStep}`,
what,
})
}
}
if (invoice.status === 'paid' && invoice.paidAt) {
events.push({
id: `${invoice.id}__paid`,
state: 'past',
when: `${formatShortDate(invoice.paidAt)} · facture encaissée`,
what: 'Marquée encaissée — relances stoppées',
})
}
return events
}
function formatShortDate(d: DateTime): string {
return d.toFormat('dd/LL/yyyy')
}
export default class InvoicesController {
/**
* GET /invoices?status=&q=&clientId=&page=
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const filters = await request.validateUsing(listInvoicesValidator)
const query = Invoice.query()
.where('organization_id', organizationId)
.preload('client')
.preload('plan')
if (filters.status && filters.status !== 'all') {
query.where('status', filters.status)
}
if (filters.clientId) {
query.where('client_id', filters.clientId)
}
if (filters.q) {
const q = filters.q.toLowerCase()
query.where((b) => {
b.whereILike('numero', `%${q}%`).orWhereExists((sub) => {
sub
.from('clients')
.whereColumn('clients.id', 'invoices.client_id')
.whereILike('clients.name', `%${q}%`)
})
})
}
const invoices = await query.exec()
// Tri : actionnable d'abord (status priority), puis échéance croissante.
invoices.sort((a, b) => {
const dp = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99)
if (dp !== 0) return dp
return a.dueDate.toMillis() - b.dueDate.toMillis()
})
// Pagination simple en V1 (cf. backend.md §6 — cursor-based plus tard).
const page = filters.page ?? 1
const total = invoices.length
const sliced = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return response.json({
data: sliced.map(serializeInvoice),
meta: { total, page },
})
}
/**
* GET /invoices/counts — compteurs par statut pour les chips dashboard.
*/
async counts({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.select('status')
.count('* as count')
.groupBy('status')
const counts = {
all: 0,
pending: 0,
in_relance: 0,
awaiting_user_confirmation: 0,
paid: 0,
litigation: 0,
cancelled: 0,
}
for (const r of rows) {
const c = Number(r.count)
counts.all += c
const s = r.status as keyof typeof counts
if (s in counts) counts[s] = c
}
return response.json({ data: counts })
}
/**
* GET /invoices/:id — détail enrichi (client + plan + timeline).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan', (q) => q.preload('steps'))
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
const data = serializeInvoice(invoice)
const relanceTasks = await RelanceTask.query()
.where('invoice_id', invoice.id)
.whereNot('status', 'cancelled')
return response.json({
data: {
...data,
client: invoice.client && {
id: invoice.client.id,
name: invoice.client.name,
email: invoice.client.email,
phone: invoice.client.phone,
address: invoice.client.address,
siret: invoice.client.siret,
},
plan: invoice.plan && {
id: invoice.plan.id,
slug: invoice.plan.slug,
name: invoice.plan.name,
steps: (invoice.plan.steps ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((s) => ({
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
},
timeline: buildTimeline(invoice, relanceTasks, await clock.now(invoice.organizationId)),
},
})
}
/**
* POST /invoices — saisie manuelle.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(createInvoiceValidator)
// Plan limit Free : bloque la création si l'org a déjà 5 actives
// après la période de grâce.
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' }
)
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(organizationId, fields, trx)
if ('errorCode' in result) {
throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: result.errorCode }
)
}
const client = result.client
// Vérification plan (s'il est fourni, doit appartenir à l'org).
let planId: string | null = null
if (fields.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.planId)
.first()
if (plan) planId = plan.id
}
return Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: DateTime.fromISO(fields.issueDate),
dueDate: DateTime.fromISO(fields.dueDate),
status: 'pending',
rubisEarned: 1, // bonus saisie initiale (cf. CLAUDE.md → glossaire)
pdfStorageKey: null,
notes: null,
paidAt: null,
},
{ client: trx }
)
})
await invoice.load('client')
await invoice.load('plan')
// Programme uniquement le check-in (envoyé à dueDate). Les relances
// client ne partent qu'après confirmation "toujours en attente".
try {
await scheduleCheckinForInvoice(invoice)
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
}
return response.status(201).json({ data: serializeInvoice(invoice) })
}
/**
* GET /invoices/:id/pdf — stream le PDF/image originel de la facture.
*
* Source : `pdfStorageKey` propagé depuis le draft d'import lors de la
* validation. 404 si la facture n'a pas de fichier (saisie manuelle).
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob
* puis affiche dans un <iframe>/<img> via objectURL.
*/
async pdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (!invoice.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour cette facture', {
status: 404,
code: 'pdf_not_available',
})
}
const ext = (invoice.pdfStorageKey.split('.').pop() ?? '').toLowerCase()
const contentType =
ext === 'pdf'
? 'application/pdf'
: ext === 'png'
? 'image/png'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'application/octet-stream'
const buffer = Buffer.from(await drive.use().getArrayBuffer(invoice.pdfStorageKey))
response.header('Content-Type', contentType)
response.header('Cache-Control', 'private, max-age=300')
response.header(
'Content-Disposition',
`inline; filename="${invoice.numero}.${ext || 'pdf'}"`
)
return response.send(buffer)
}
/**
* POST /invoices/:id/mark-paid
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
* et sur organization.rubisCount). Annule toutes les relances futures.
*/
async markPaid({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan')
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (invoice.status === 'paid') {
// Idempotent : déjà payée, on renvoie l'état courant sans bumper.
return response.json({ data: serializeInvoice(invoice) })
}
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = await clock.now(invoice.organizationId)
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
// Bump du compteur agrégé sur l'organisation
await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
// Journal d'activité (cf. dashboard activity feed).
await recordActivity({
organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
// Annule toutes les relances + le check-in programmés pour cette
// facture (idempotent, BullMQ.remove peut échouer silencieusement
// si le job a déjà été consommé).
await cancelFutureRelances(invoice.id, trx)
await cancelCheckinForInvoice(invoice.id, trx)
})
// Enqueue le mail de remerciement après commit. Cohérent avec le flow
// check-in : mark-paid manuel = même intention utilisateur ("j'ai été payé").
// L'early-return en haut de la méthode (idempotence si déjà payée) garantit
// qu'on n'arrive ici que sur transition réelle * → paid.
await enqueuePaymentThanks(invoice.id)
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)
}
}