diff --git a/apps/api/app/controllers/clients_controller.ts b/apps/api/app/controllers/clients_controller.ts index bc179bb..8e9447d 100644 --- a/apps/api/app/controllers/clients_controller.ts +++ b/apps/api/app/controllers/clients_controller.ts @@ -176,6 +176,17 @@ export default class ClientsController { 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), notes: payload.notes ?? null, }) diff --git a/apps/api/app/controllers/invoice_settings_controller.ts b/apps/api/app/controllers/invoice_settings_controller.ts new file mode 100644 index 0000000..a747cb7 --- /dev/null +++ b/apps/api/app/controllers/invoice_settings_controller.ts @@ -0,0 +1,81 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { Exception } from '@adonisjs/core/exceptions' + +import Organization from '#models/organization' +import { + type InvoiceSettings, + resolveInvoiceSettings, + mergeInvoiceSettings, + validateInvoiceSettings, + normalizeIban, +} from '#services/invoice_settings' +import { updateInvoiceSettingsValidator } from '#validators/invoice_settings' + +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 +} + +/** + * InvoiceSettingsController — paramétrage de la facturation par org. + * + * Routes (toutes sous /api/v1/organizations/me/invoice-settings, auth requise) : + * - GET / → settings courants + valeurs résolues (defaults appliqués) + * - PATCH / → maj partielle des settings (null = reset au default) + * + * Pas de gating de plan : toute org peut paramétrer sa facturation. Le + * gating porte sur la création (`canCreateInvoices`). + * + * Le PATCH applique la sémantique "null = reset au default" : envoyer + * `{ accentColor: null }` retire l'override sur ce champ (l'accent + * repart en brand.primary ou rubis #9F1239). + */ +export default class InvoiceSettingsController { + async show({ auth, response }: HttpContext) { + const orgId = requireOrgId(auth) + const org = await Organization.findOrFail(orgId) + const settings = (org.invoiceSettings ?? null) as InvoiceSettings | null + return response.json({ + data: { + settings: settings ?? {}, + resolved: resolveInvoiceSettings(org), + }, + }) + } + + async update({ auth, request, response }: HttpContext) { + const orgId = requireOrgId(auth) + const org = await Organization.findOrFail(orgId) + + const payload = await request.validateUsing(updateInvoiceSettingsValidator) + + // Cast vers le shape applicatif : Vine retourne un objet typé strict que + // mergeInvoiceSettings peut consommer directement. + const patch = payload as Partial + + // Normalise l'IBAN (suppression espaces + uppercase) avant stockage. + if (patch.rib?.iban) { + patch.rib = { ...patch.rib, iban: normalizeIban(patch.rib.iban) } + } + + const err = validateInvoiceSettings(patch) + if (err) { + return response.status(422).json({ errors: [{ message: err }] }) + } + + const current = (org.invoiceSettings ?? null) as InvoiceSettings | null + const merged = mergeInvoiceSettings(current, patch) + org.invoiceSettings = Object.keys(merged).length === 0 ? null : merged + await org.save() + + return response.json({ + data: { + settings: org.invoiceSettings ?? {}, + resolved: resolveInvoiceSettings(org), + }, + }) + } +} diff --git a/apps/api/app/controllers/invoice_themes_controller.ts b/apps/api/app/controllers/invoice_themes_controller.ts new file mode 100644 index 0000000..b60474f --- /dev/null +++ b/apps/api/app/controllers/invoice_themes_controller.ts @@ -0,0 +1,50 @@ +import type { HttpContext } from '@adonisjs/core/http' + +/** + * InvoiceThemesController — galerie des thèmes disponibles pour l'éditeur. + * + * Route (auth requise) : + * - GET /api/v1/invoice-themes → liste les thèmes pré-faits + * + * Le rendu lui-même (composants React PDF) vit dans + * `packages/ui/invoice-templates/.tsx`. Cet endpoint ne retourne que + * les métadonnées (slug + name + description) pour peupler la galerie de + * sélection dans /parametres/facturation et l'éditeur /factures/nouvelle. + * + * Note : pas de pagination, pas de filtre — la liste est petite (4 thèmes + * en V1) et entièrement statique côté serveur. Si on ajoute des thèmes + * payants (Pro/Business) plus tard, on filtrera ici selon `org.plan`. + */ + +const INVOICE_THEMES = [ + { + slug: 'classique', + name: 'Classique', + description: + 'Sobre et sérieux, header texte centré. Pour les cabinets et professions réglementées.', + }, + { + slug: 'moderne', + name: 'Moderne', + description: + 'Bandeau coloré en header, typo Bricolage. Pour les agences et studios.', + }, + { + slug: 'minimal', + name: 'Minimal', + description: + 'Noir et blanc, aéré, aucun ornement. Pour les indépendants et les designers.', + }, + { + slug: 'elegant', + name: 'Élégant', + description: + 'Filets fins, watermark logo discret. Pour les boutiques premium et l’artisanat.', + }, +] as const + +export default class InvoiceThemesController { + async index({ response }: HttpContext) { + return response.json({ data: INVOICE_THEMES }) + } +} diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 4481f1a..8a4165b 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -1,8 +1,15 @@ 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 } from '#validators/invoice' +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' @@ -13,6 +20,10 @@ 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' @@ -443,4 +454,204 @@ export default class InvoicesController { 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, + { 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 `