rubis/apps/api/app/controllers/clients_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

246 lines
7.7 KiB
TypeScript

import Client from '#models/client'
import Invoice from '#models/invoice'
import ClientTransformer from '#transformers/client_transformer'
import InvoiceTransformer from '#transformers/invoice_transformer'
import { createClientValidator, updateClientValidator } from '#validators/client'
import { bulkComputeClientStats } from '#services/client_stats'
import { computeClientTimeseries, type RangeMonths } from '#services/dashboard'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import vine from '@vinejs/vine'
const timeseriesValidator = vine.create({
range: vine.number().in([3, 6, 12]).optional(),
})
// Priorité d'affichage : ce qui est actionnable en haut.
const INVOICE_STATUS_PRIORITY: Record<string, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
}
/**
* Petite cohérence d'identification orgnisation : si l'utilisateur
* n'en a pas, on est dans un état illégal V1 — on bloque ferme.
*/
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
}
/**
* Sérialisation directe (instanciation manuelle du transformer pour
* éviter le wrapper Item — utile quand on doit fusionner des stats
* computed par-dessus chaque client dans une liste).
*/
function serializeClient(c: Client) {
return new ClientTransformer(c).toObject()
}
export default class ClientsController {
/**
* GET /clients?withStats=1&q=
*
* Sans `withStats`, retour à plat (utilisé par le combobox de saisie).
* Avec `withStats`, chaque client est enrichi des compteurs de factures
* et trié par actionnabilité (retards d'abord, puis activité récente).
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const withStats = request.input('withStats') === '1'
const q = (request.input('q') ?? '').toString().trim().toLowerCase()
const query = Client.query().where('organization_id', organizationId)
if (q.length > 0) {
query.where((b) => {
b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`)
})
}
const clients = await query.exec()
if (!withStats) {
// Tri alphabétique par défaut pour le combobox.
clients.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
return response.json({ data: clients.map(serializeClient) })
}
const statsMap = await bulkComputeClientStats(
organizationId,
clients.map((c) => c.id)
)
const enriched = clients.map((c) => ({
...serializeClient(c),
...statsMap.get(c.id)!,
}))
// Tri actionnable : retards d'abord, puis activité récente.
enriched.sort((a, b) => {
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
return b.lateInvoiceCount - a.lateInvoiceCount
}
const aLast = a.lastActivityAt ?? ''
const bLast = b.lastActivityAt ?? ''
return bLast.localeCompare(aLast)
})
return response.json({ data: enriched })
}
/**
* GET /clients/:id — détail enrichi (stats + invoices à venir).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
const stats = statsMap.get(client.id)!
// Factures du client — actionnables en premier, puis échéance asc.
const invoices = await Invoice.query()
.where('organization_id', organizationId)
.where('client_id', client.id)
.preload('client')
.preload('plan')
.exec()
invoices.sort((a, b) => {
const dp =
(INVOICE_STATUS_PRIORITY[a.status] ?? 99) -
(INVOICE_STATUS_PRIORITY[b.status] ?? 99)
if (dp !== 0) return dp
return a.dueDate.toMillis() - b.dueDate.toMillis()
})
return response.json({
data: {
...serializeClient(client),
...stats,
invoices: invoices.map((inv) => new InvoiceTransformer(inv).toObject()),
},
})
}
/**
* POST /clients — création manuelle.
* Détecte les doublons de nom (case-insensitive) et renvoie 409 avec
* la fiche existante pour permettre au SPA de proposer "voir le client".
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createClientValidator)
// Doublon → 409 (cf. clients.ts MSW pour le contrat exact).
const existing = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', payload.name)
.first()
if (existing) {
return response.status(409).json({
errors: [
{
code: 'duplicate_client',
message: `Un client nommé « ${existing.name} » existe déjà.`,
field: 'name',
},
],
existing: serializeClient(existing),
})
}
const created = await Client.create({
organizationId,
name: payload.name,
email: payload.email,
contactFirstName: payload.contactFirstName ?? null,
contactLastName: payload.contactLastName ?? null,
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,
})
return response.status(201).json({ data: serializeClient(created) })
}
/**
* GET /clients/:id/timeseries?range=6 — encaissé mensuel pour ce client.
* Utilisé par le mini-chart sur la fiche client (santé du compte).
*/
async timeseries({ auth, request, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const exists = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!exists) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
const { range } = await request.validateUsing(timeseriesValidator, {
data: {
range: request.input('range') ? Number(request.input('range')) : undefined,
},
})
const data = await computeClientTimeseries(
organizationId,
params.id,
(range ?? 6) as RangeMonths
)
return response.json({ data })
}
/**
* PATCH /clients/:id — édition partielle.
*/
async update({ auth, request, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updateClientValidator)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
client.merge(payload)
await client.save()
return response.json({ data: serializeClient(client) })
}
}