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 = { 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