Implémente les 4 thèmes de factures (Classique, Moderne, Minimal, Élégant) en composants React PDF et remplace les stubs Phase 1 par la vraie génération + upload MinIO. Templates (app/pdf-templates/) - common.tsx : props partagées, formatters fr-FR (cents → euros, dates longues, taux TVA), palette neutre. - classique : sobre, header texte centré, filets fins. Pour les cabinets et professions réglementées. - moderne : bandeau coloré pleine largeur, logo dans le bandeau. Pour les agences et studios. - minimal : noir et blanc, aéré, accent uniquement sur le numéro. Pour les indépendants et les designers. - elegant : Times Roman, filets fins, titre centré encadré, italique sur le pied légal. Pour les boutiques premium. - index.tsx : dispatcher slug → composant + renderInvoiceToBuffer. Génération - media_storage : nouveau scope `invoice-pdf` (`invoices/<orgId>/<uuid>.pdf`) et fonction `uploadBuffer(buffer, scope, subPath?)` pour stocker les buffers générés en mémoire (vs. uploads multipart existants). - invoice_pdf : `generateInvoicePdf` rend + upload, `previewInvoicePdf` rend en Buffer pour stream HTTP direct. - InvoicesController.pdf : lazy regenerate si pdf_storage_key est null sur une facture native (cas où la génération initiale a échoué). - InvoicesController.previewPdf : synthétise un clientSnapshot depuis les données live, passe dans le pipeline standard. - InvoicesController.storeNative : appelle la vraie génération en post-commit, log + continue si échec. Conformité Factur-X (V1.5) : la structure de génération est un point d'extension Buffer → Buffer ; l'injection d'un XML CII en pièce jointe PDF sera ajoutée sans toucher aux templates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
709 lines
25 KiB
TypeScript
709 lines
25 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 (généré ou uploadé) de la facture.
|
|
*
|
|
* Cas couverts :
|
|
* - Facture OCR/manuelle (`pdfStorageKey` propagé du draft) → stream tel quel.
|
|
* - Facture native déjà rendue (`pdfStorageKey` non-null) → stream depuis MinIO.
|
|
* - Facture native avec génération échouée (`isNative=true` + `pdfStorageKey=null`)
|
|
* → lazy regenerate à la volée, persiste, puis stream.
|
|
* - Facture sans fichier (saisie manuelle pré-feature, jamais native) → 404.
|
|
*
|
|
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob puis
|
|
* affiche dans un <iframe>/<img> via objectURL.
|
|
*/
|
|
async pdf({ auth, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
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' })
|
|
}
|
|
|
|
// Lazy regenerate : facture native dont la génération a échoué → on retente.
|
|
if (!invoice.pdfStorageKey && invoice.isNative) {
|
|
try {
|
|
const org = await Organization.findOrFail(organizationId)
|
|
const resolvedSettings = resolveInvoiceSettings(org)
|
|
const generated = await generateInvoicePdf({
|
|
invoice,
|
|
resolvedSettings,
|
|
organization: org,
|
|
})
|
|
invoice.pdfStorageKey = generated.storageKey
|
|
invoice.pdfGeneratedAt = DateTime.utc()
|
|
await invoice.save()
|
|
} catch (err) {
|
|
logger.warn({ err, invoiceId: invoice.id }, 'lazy invoice pdf regeneration failed')
|
|
throw new Exception('Impossible de générer le PDF de la facture', {
|
|
status: 500,
|
|
code: 'pdf_generation_failed',
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!invoice.pdfStorageKey) {
|
|
throw new Exception('Aucun PDF stocké pour cette facture', {
|
|
status: 404,
|
|
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. Échec = on log et on continue, la
|
|
// facture est créée et le PDF sera régénérable plus tard (idempotent).
|
|
try {
|
|
const org = await Organization.findOrFail(organizationId)
|
|
const resolvedSettings = resolveInvoiceSettings(org)
|
|
const generated = await generateInvoicePdf({
|
|
invoice,
|
|
resolvedSettings,
|
|
organization: org,
|
|
})
|
|
invoice.pdfStorageKey = generated.storageKey
|
|
invoice.pdfGeneratedAt = DateTime.utc()
|
|
await invoice.save()
|
|
} catch (err) {
|
|
logger.warn({ err, invoiceId: invoice.id }, 'native invoice pdf generation failed')
|
|
}
|
|
|
|
// Programme le check-in (envoyé à dueDate) — même mécanique que `store`.
|
|
if (!(payload.draft ?? false)) {
|
|
try {
|
|
await scheduleCheckinForInvoice(invoice)
|
|
} catch (err) {
|
|
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
|
|
}
|
|
}
|
|
|
|
return response.status(201).json({ data: serializeInvoice(invoice) })
|
|
}
|
|
|
|
/**
|
|
* POST /invoices/preview-pdf — preview d'un PDF sans persister.
|
|
*
|
|
* Mêmes champs que `storeNative` (sauf `draft`) — le serveur recalcule
|
|
* les totaux et stream le PDF (`application/pdf`). Utilisé par l'éditeur
|
|
* pour afficher le rendu dans un `<iframe>` (debounced 500ms côté UI
|
|
* pour éviter le spam de requêtes pendant la saisie).
|
|
*/
|
|
async previewPdf({ auth, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const payload = await request.validateUsing(previewInvoiceValidator)
|
|
|
|
// Vérifie l'appartenance du client à l'org (sécurité : pas de leak inter-org).
|
|
const client = await Client.query()
|
|
.where('organization_id', organizationId)
|
|
.where('id', payload.clientId)
|
|
.first()
|
|
if (!client) {
|
|
throw new Exception('Client introuvable pour cette organisation', {
|
|
status: 422,
|
|
code: 'client_not_found',
|
|
})
|
|
}
|
|
|
|
const totals = computeInvoiceTotals(payload.lines)
|
|
|
|
const org = await Organization.findOrFail(organizationId)
|
|
const resolvedSettings = resolveInvoiceSettings(org)
|
|
|
|
// Synthétise un clientSnapshot à partir du client live (pas encore figé
|
|
// puisque la facture n'est pas émise).
|
|
const clientSnapshotForPreview = {
|
|
name: client.name,
|
|
email: client.email,
|
|
contactFirstName: client.contactFirstName,
|
|
contactLastName: client.contactLastName,
|
|
phone: client.phone,
|
|
siret: client.siret,
|
|
siren: (client as unknown as { siren: string | null }).siren ?? null,
|
|
tvaIntra: (client as unknown as { tvaIntra: string | null }).tvaIntra ?? null,
|
|
addressLine1: (client as unknown as { addressLine1: string | null }).addressLine1 ?? null,
|
|
addressLine2: (client as unknown as { addressLine2: string | null }).addressLine2 ?? null,
|
|
addressZip: (client as unknown as { addressZip: string | null }).addressZip ?? null,
|
|
addressCity: (client as unknown as { addressCity: string | null }).addressCity ?? null,
|
|
addressCountry:
|
|
(client as unknown as { addressCountry: string | null }).addressCountry ?? null,
|
|
}
|
|
|
|
// Invoice "virtuel" non-persisté pour passer dans le pipeline de rendu.
|
|
const virtualInvoice = new Invoice()
|
|
virtualInvoice.organizationId = organizationId
|
|
virtualInvoice.clientId = client.id
|
|
virtualInvoice.numero = '[APERÇU]'
|
|
virtualInvoice.sequenceNumber = null
|
|
virtualInvoice.amountTtcCents = totals.amountTtcCents
|
|
virtualInvoice.amountHtCents = totals.amountHtCents
|
|
virtualInvoice.amountTvaCents = totals.amountTvaCents
|
|
virtualInvoice.tvaBreakdown = totals.tvaBreakdown
|
|
virtualInvoice.lines = totals.lines
|
|
virtualInvoice.issueDate = DateTime.fromISO(payload.issueDate)
|
|
virtualInvoice.dueDate = DateTime.fromISO(payload.dueDate)
|
|
virtualInvoice.paymentTermsDays = payload.paymentTermsDays
|
|
virtualInvoice.themeSlug = payload.themeSlug
|
|
virtualInvoice.themeAccentColor = payload.accentColor
|
|
virtualInvoice.footerNotes = payload.footerNotes ?? null
|
|
virtualInvoice.isNative = true
|
|
virtualInvoice.clientSnapshot = clientSnapshotForPreview
|
|
virtualInvoice.issuerSnapshot = resolvedSettings.issuer
|
|
|
|
const pdf = await previewInvoicePdf({
|
|
invoice: virtualInvoice,
|
|
resolvedSettings,
|
|
organization: org,
|
|
})
|
|
|
|
response.header('Content-Type', 'application/pdf')
|
|
response.header('Cache-Control', 'no-store')
|
|
return response.send(pdf)
|
|
}
|
|
}
|