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>
246 lines
7.7 KiB
TypeScript
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) })
|
|
}
|
|
}
|