From 005af557c22fa534d738da57bb0450e9bf11f3c0 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 14:33:46 +0200 Subject: [PATCH] feat(api): domaine Invoice + endpoints CRUD + branche stats Client/Plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration invoices : uuid id, organization_id FK CASCADE, client_id FK RESTRICT (on n'efface pas les factures si l'utilisateur supprime un client par erreur — audit/comptable), plan_id FK SET NULL, numero, amount_ttc_cents (int, jamais float), issue_date, due_date, status ENUM PG natif (pending/awaiting_user_confirmation/in_relance/paid/litigation/cancelled), pdf_storage_key, notes, rubis_earned, paid_at. Indexes (org,status), (org,client_id), (org,due_date), unique (org,numero). Modèles : Invoice avec belongsTo Organization/Client/Plan. Client et Plan étendus avec hasMany Invoice maintenant que la table existe. Endpoints : - GET /invoices : filtres status/q/clientId/page, tri actionnable (awaiting_user_confirmation puis in_relance puis pending puis litigation puis paid puis cancelled), pagination simple 50/page (cursor-based en V2). - GET /invoices/counts : compteurs par statut pour les chips dashboard, requête agrégée groupBy. - GET /invoices/:id : détail enrichi avec client + plan préchargés + timeline composée par buildTimeline() (étapes du plan calées sur due_date, états past/current/future). - POST /invoices : saisie manuelle. Résolution client en 3 étapes (clientId → match par nom → création à la volée avec email REQUIS, sinon 422 client_email_required). Bonus +1 rubis à la création. - POST /invoices/:id/mark-paid : status=paid + paid_at + bonus +1 rubis (sur invoice + sur organization.rubis_count). Idempotent. L'ordre des routes /invoices/counts AVANT /invoices/:id est critique sinon `:id` matche "counts". Branche les vraies stats : - ClientStats : agrégation PG une seule requête (count, count actives, count en retard, paid_count, sum paid_cents, sum pending_cents, last_activity) avec FILTER clauses et casting enum::text. Plus de TODO/zéros. - PlansController : usageCount calculé pareil (factures actives référençant le plan). Skip pour l'instant (ImportBatch domain à venir) : POST /invoices/upload, GET /invoices/import-batch/*, validate/skip drafts. --- .../app/controllers/invoices_controller.ts | 392 ++++++++++++++++++ apps/api/app/controllers/plans_controller.ts | 27 +- apps/api/app/models/client.ts | 8 +- apps/api/app/models/invoice.ts | 17 + apps/api/app/models/plan.ts | 4 + apps/api/app/services/client_stats.ts | 66 ++- .../app/transformers/invoice_transformer.ts | 30 ++ apps/api/app/validators/invoice.ts | 42 ++ .../1778080000500_create_invoices_table.ts | 79 ++++ apps/api/database/schema.ts | 35 ++ apps/api/database/schema_rules.ts | 8 + apps/api/start/routes.ts | 19 + 12 files changed, 709 insertions(+), 18 deletions(-) create mode 100644 apps/api/app/controllers/invoices_controller.ts create mode 100644 apps/api/app/models/invoice.ts create mode 100644 apps/api/app/transformers/invoice_transformer.ts create mode 100644 apps/api/app/validators/invoice.ts create mode 100644 apps/api/database/migrations/1778080000500_create_invoices_table.ts diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts new file mode 100644 index 0000000..66f314d --- /dev/null +++ b/apps/api/app/controllers/invoices_controller.ts @@ -0,0 +1,392 @@ +import Invoice from '#models/invoice' +import Client from '#models/client' +import Plan from '#models/plan' +import InvoiceTransformer from '#transformers/invoice_transformer' +import { + createInvoiceValidator, + listInvoicesValidator, +} 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 type { TransactionClientContract } from '@adonisjs/lucid/types/database' + +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() +} + +/** + * Résolution client à la création de facture. + * + * Priorité : + * 1. clientId fourni → utilise tel quel (combobox a sélectionné une fiche). + * 2. match par nom (case-insensitive) sur les clients existants. + * 3. création à la volée → email REQUIS (sans email pas de relance possible). + */ +async function resolveClient( + organizationId: string, + fields: { + clientId?: string + clientName: string + clientEmail?: string | null + }, + trx: TransactionClientContract +): Promise { + if (fields.clientId) { + const c = await Client.query({ client: trx }) + .where('organization_id', organizationId) + .where('id', fields.clientId) + .first() + if (c) return c + } + + const matched = await Client.query({ client: trx }) + .where('organization_id', organizationId) + .whereILike('name', fields.clientName) + .first() + if (matched) return matched + + // Création à la volée : email obligatoire. + if (!fields.clientEmail) { + return { errorCode: 'client_email_required' } + } + + return Client.create( + { + organizationId, + name: fields.clientName, + email: fields.clientEmail, + phone: null, + address: null, + siret: null, + notes: null, + }, + { client: trx } + ) +} + +/** + * 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): 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 = DateTime.now().toMillis() + 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 stepDate = DateTime.fromMillis(sendMs) + const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}` + + let state: 'past' | 'current' | 'future' + if (sendMs < nowMs) state = 'past' + else if (!currentSet) { + state = 'current' + currentSet = true + } else state = 'future' + + events.push({ + id: `${invoice.id}__step_${step.order}`, + state, + when: `${formatShortDate(stepDate)} · ${labelStep}`, + what: + state === 'past' + ? `Email envoyé · "${step.subject.replace('{{numero}}', invoice.numero)}"` + : `Email programmé · "${step.subject.replace('{{numero}}', invoice.numero)}"`, + }) + } + } + + 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) + 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), + }, + }) + } + + /** + * POST /invoices — saisie manuelle. + */ + async store({ auth, request, response }: HttpContext) { + const organizationId = requireOrgId(auth) + const fields = await request.validateUsing(createInvoiceValidator) + + const invoice = await db.transaction(async (trx) => { + const clientOrError = await resolveClient(organizationId, fields, trx) + if ('errorCode' in clientOrError) { + // Throw with explicit shape pour l'exception handler + throw new Exception( + 'Email du client requis — Rubis en a besoin pour envoyer les relances.', + { status: 422, code: clientOrError.errorCode } + ) + } + const client = clientOrError + + // 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') + + return response.status(201).json({ data: serializeInvoice(invoice) }) + } + + /** + * POST /invoices/:id/mark-paid + * Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned + * et sur organization.rubisCount). + */ + 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 = DateTime.now() + 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) + }) + + return response.json({ data: serializeInvoice(invoice) }) + } +} diff --git a/apps/api/app/controllers/plans_controller.ts b/apps/api/app/controllers/plans_controller.ts index ad22dd1..03d08ea 100644 --- a/apps/api/app/controllers/plans_controller.ts +++ b/apps/api/app/controllers/plans_controller.ts @@ -6,6 +6,8 @@ import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import db from '@adonisjs/lucid/services/db' +const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')" + function requireOrgId(auth: HttpContext['auth']): string { const user = auth.getUserOrFail() if (!user.organizationId) { @@ -19,18 +21,31 @@ function serializePlan(p: Plan) { } /** - * Compte combien de factures actives référencent chaque plan d'une org. - * Utilisé pour enrichir la liste avec un badge d'usage. - * - * @todo Brancher sur Invoice quand le domaine arrive — pour l'instant 0 - * partout (le contrat reste stable côté SPA). + * Compte combien de factures actives (non payées, non annulées) référencent + * chaque plan d'une org. Utilisé pour enrichir la liste avec un badge "X + * factures utilisent ce plan" — utile avant édition pour signaler l'impact. */ async function bulkComputePlanUsage( - _organizationId: string, + organizationId: string, planIds: string[] ): Promise> { const map = new Map() for (const id of planIds) map.set(id, 0) + + if (planIds.length === 0) return map + + const rows = await db + .from('invoices') + .where('organization_id', organizationId) + .whereIn('plan_id', planIds) + .whereRaw(`status::text in ${ACTIVE_INVOICE_STATUSES}`) + .select('plan_id') + .count('* as count') + .groupBy('plan_id') + + for (const r of rows) { + map.set(r.plan_id, Number(r.count)) + } return map } diff --git a/apps/api/app/models/client.ts b/apps/api/app/models/client.ts index 22db4fb..1d39c9a 100644 --- a/apps/api/app/models/client.ts +++ b/apps/api/app/models/client.ts @@ -1,11 +1,13 @@ import { ClientSchema } from '#database/schema' -import { belongsTo } from '@adonisjs/lucid/orm' -import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { belongsTo, hasMany } from '@adonisjs/lucid/orm' +import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' import Organization from '#models/organization' +import Invoice from '#models/invoice' export default class Client extends ClientSchema { @belongsTo(() => Organization) declare organization: BelongsTo - // hasMany Invoice — sera ajouté quand le domaine Invoice arrivera. + @hasMany(() => Invoice) + declare invoices: HasMany } diff --git a/apps/api/app/models/invoice.ts b/apps/api/app/models/invoice.ts new file mode 100644 index 0000000..0b97e0e --- /dev/null +++ b/apps/api/app/models/invoice.ts @@ -0,0 +1,17 @@ +import { InvoiceSchema } from '#database/schema' +import { belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Organization from '#models/organization' +import Client from '#models/client' +import Plan from '#models/plan' + +export default class Invoice extends InvoiceSchema { + @belongsTo(() => Organization) + declare organization: BelongsTo + + @belongsTo(() => Client) + declare client: BelongsTo + + @belongsTo(() => Plan) + declare plan: BelongsTo +} diff --git a/apps/api/app/models/plan.ts b/apps/api/app/models/plan.ts index b473b2d..99010fa 100644 --- a/apps/api/app/models/plan.ts +++ b/apps/api/app/models/plan.ts @@ -3,6 +3,7 @@ import { belongsTo, hasMany } from '@adonisjs/lucid/orm' import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' import Organization from '#models/organization' import PlanStep from '#models/plan_step' +import Invoice from '#models/invoice' export default class Plan extends PlanSchema { @belongsTo(() => Organization) @@ -10,4 +11,7 @@ export default class Plan extends PlanSchema { @hasMany(() => PlanStep, { foreignKey: 'planId' }) declare steps: HasMany + + @hasMany(() => Invoice) + declare invoices: HasMany } diff --git a/apps/api/app/services/client_stats.ts b/apps/api/app/services/client_stats.ts index 349bb59..ff2837f 100644 --- a/apps/api/app/services/client_stats.ts +++ b/apps/api/app/services/client_stats.ts @@ -1,9 +1,9 @@ +import db from '@adonisjs/lucid/services/db' + /** * Stats agrégées d'un client. Calculées on-the-fly à partir des invoices - * (V1 : pas de cache, le volume reste raisonnable). - * - * Tant que le domaine Invoice n'est pas câblé, on retourne EMPTY pour - * tous les clients — le contrat reste stable côté SPA. + * (V1 : pas de cache, le volume reste raisonnable). Si le perf devient un + * sujet, on cachera dans Redis avec invalidation post-mutation invoice. */ export type ClientStats = { invoiceCount: number @@ -26,18 +26,66 @@ export const EMPTY_CLIENT_STATS: ClientStats = { } /** - * Calcule les stats pour un ensemble de clients d'une org. - * @returns Map clientId → ClientStats + * Calcule les stats pour un ensemble de clients d'une org en une seule + * requête agrégée par client_id. Les clients sans facture reçoivent EMPTY. * - * @todo Implémenter quand Invoice arrive — pour l'instant tout le monde a 0. + * @returns Map clientId → ClientStats */ export async function bulkComputeClientStats( - _organizationId: string, + organizationId: string, clientIds: string[] ): Promise> { const map = new Map() for (const id of clientIds) { - map.set(id, EMPTY_CLIENT_STATS) + map.set(id, { ...EMPTY_CLIENT_STATS }) } + + if (clientIds.length === 0) return map + + const today = new Date() + today.setHours(0, 0, 0, 0) + + const ACTIVE = "('pending','in_relance','awaiting_user_confirmation')" + + const rows = await db + .from('invoices') + .where('organization_id', organizationId) + .whereIn('client_id', clientIds) + .select('client_id') + .select(db.raw('count(*)::int as invoice_count')) + .select(db.raw(`count(*) filter (where status::text in ${ACTIVE})::int as active_count`)) + .select( + db.raw( + `count(*) filter (where status::text in ${ACTIVE} and due_date < ?)::int as late_count`, + [today] + ) + ) + .select(db.raw(`count(*) filter (where status = 'paid')::int as paid_count`)) + .select( + db.raw(`coalesce(sum(amount_ttc_cents) filter (where status = 'paid'), 0)::int as paid_cents`) + ) + .select( + db.raw( + `coalesce(sum(amount_ttc_cents) filter (where status::text in ${ACTIVE}), 0)::int as pending_cents` + ) + ) + .select(db.raw('max(updated_at) as last_activity')) + .groupBy('client_id') + + for (const r of rows) { + map.set(r.client_id, { + invoiceCount: r.invoice_count, + activeInvoiceCount: r.active_count, + lateInvoiceCount: r.late_count, + paidInvoiceCount: r.paid_count, + paidLifetimeCents: r.paid_cents, + pendingLifetimeCents: r.pending_cents, + lastActivityAt: + r.last_activity instanceof Date + ? r.last_activity.toISOString() + : (r.last_activity as string | null), + }) + } + return map } diff --git a/apps/api/app/transformers/invoice_transformer.ts b/apps/api/app/transformers/invoice_transformer.ts new file mode 100644 index 0000000..b5f846e --- /dev/null +++ b/apps/api/app/transformers/invoice_transformer.ts @@ -0,0 +1,30 @@ +import type Invoice from '#models/invoice' +import { BaseTransformer } from '@adonisjs/core/transformers' + +export default class InvoiceTransformer extends BaseTransformer { + toObject() { + const i = this.resource + return { + id: i.id, + organizationId: i.organizationId, + clientId: i.clientId, + // Le SPA affiche `clientName` dans la liste — c'est lu depuis la + // relation préchargée, sinon vide. La V1 MSW dénormalisait ce champ + // dans la table invoice, on préfère le préchargement côté API. + clientName: i.client?.name ?? '', + numero: i.numero, + amountTtcCents: i.amountTtcCents, + issueDate: i.issueDate.toISO()!, + dueDate: i.dueDate.toISO()!, + status: i.status, + planId: i.planId, + planName: i.plan?.name ?? null, + pdfStorageKey: i.pdfStorageKey, + notes: i.notes, + rubisEarned: i.rubisEarned, + paidAt: i.paidAt?.toISO() ?? null, + createdAt: i.createdAt.toISO()!, + updatedAt: i.updatedAt?.toISO() ?? i.createdAt.toISO()!, + } + } +} diff --git a/apps/api/app/validators/invoice.ts b/apps/api/app/validators/invoice.ts new file mode 100644 index 0000000..7ee4f6b --- /dev/null +++ b/apps/api/app/validators/invoice.ts @@ -0,0 +1,42 @@ +import vine from '@vinejs/vine' + +const INVOICE_STATUSES = [ + 'pending', + 'awaiting_user_confirmation', + 'in_relance', + 'paid', + 'litigation', + 'cancelled', +] as const + +/** + * Filtres GET /invoices?status=&q=&clientId=&page= + */ +export const listInvoicesValidator = vine.create({ + status: vine.enum([...INVOICE_STATUSES, 'all'] as const).optional(), + q: vine.string().maxLength(120).optional(), + clientId: vine.string().uuid().optional(), + page: vine.number().min(1).optional(), +}) + +/** + * POST /invoices — saisie manuelle. + * + * Le SPA peut envoyer : + * - clientId d'un client existant (combobox a sélectionné une fiche), OU + * - clientName seul → on tente de matcher par nom, sinon création à la + * volée mais alors clientEmail est REQUIS (pivot produit, cf. Client). + * + * On ne peut pas exprimer "email requis si pas de match" en Vine pur, donc + * c'est le contrôleur qui retourne 422 `client_email_required` si besoin. + */ +export const createInvoiceValidator = vine.create({ + clientId: vine.string().uuid().optional(), + clientName: vine.string().minLength(2).maxLength(120), + clientEmail: vine.string().email().nullable().optional(), + numero: vine.string().minLength(1).maxLength(50), + amountTtcCents: vine.number().min(1), + issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/), + dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/), + planId: vine.string().uuid().nullable().optional(), +}) diff --git a/apps/api/database/migrations/1778080000500_create_invoices_table.ts b/apps/api/database/migrations/1778080000500_create_invoices_table.ts new file mode 100644 index 0000000..2da2884 --- /dev/null +++ b/apps/api/database/migrations/1778080000500_create_invoices_table.ts @@ -0,0 +1,79 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'invoices' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()')) + table + .uuid('organization_id') + .notNullable() + .references('id') + .inTable('organizations') + .onDelete('CASCADE') + table + .uuid('client_id') + .notNullable() + .references('id') + .inTable('clients') + // RESTRICT plutôt que CASCADE : on ne veut pas qu'un user qui delete + // par erreur un client perde toutes ses factures (audit + comptable). + .onDelete('RESTRICT') + // Plan nullable — une facture peut être créée sans plan assigné. + table + .uuid('plan_id') + .nullable() + .references('id') + .inTable('plans') + .onDelete('SET NULL') + + table.string('numero', 50).notNullable() + // Montants : toujours en centimes (int), jamais float. + table.integer('amount_ttc_cents').notNullable() + table.timestamp('issue_date').notNullable() + table.timestamp('due_date').notNullable() + + table + .enum( + 'status', + [ + 'pending', + 'awaiting_user_confirmation', + 'in_relance', + 'paid', + 'litigation', + 'cancelled', + ], + { useNative: true, enumName: 'invoice_status' } + ) + .notNullable() + .defaultTo('pending') + + table.string('pdf_storage_key', 500).nullable() + table.text('notes').nullable() + // Compteur de rubis générés par cette facture (cf. CLAUDE.md → glossaire). + // Bonus initial à la création (1) + bonus à l'encaissement (1). + table.integer('rubis_earned').notNullable().defaultTo(0) + table.timestamp('paid_at').nullable() + + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + + // Indexes : + // - filtre par status (chips dashboard) : (org, status) + // - filtre par client : (org, client_id) + // - tri par échéance : (org, due_date) + table.index(['organization_id', 'status']) + table.index(['organization_id', 'client_id']) + table.index(['organization_id', 'due_date']) + // Numéro unique par org : pas de doublon de numero F-2026-0042 + table.unique(['organization_id', 'numero']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + this.schema.raw('DROP TYPE IF EXISTS invoice_status') + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 4a64756..5d76aaa 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -57,6 +57,41 @@ export class ClientSchema extends BaseModel { declare updatedAt: DateTime | null } +export class InvoiceSchema extends BaseModel { + static $columns = ['amountTtcCents', 'clientId', 'createdAt', 'dueDate', 'id', 'issueDate', 'notes', 'numero', 'organizationId', 'paidAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'status', 'updatedAt'] as const + $columns = InvoiceSchema.$columns + @column() + declare amountTtcCents: number + @column() + declare clientId: string + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column.dateTime() + declare dueDate: DateTime + @column({ isPrimary: true }) + declare id: string + @column.dateTime() + declare issueDate: DateTime + @column() + declare notes: string | null + @column() + declare numero: string + @column() + declare organizationId: string + @column.dateTime() + declare paidAt: DateTime | null + @column() + declare pdfStorageKey: string | null + @column() + declare planId: string | null + @column() + declare rubisEarned: number + @column() + declare status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'paid' | 'litigation' | 'cancelled' + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + export class OrganizationSchema extends BaseModel { static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const $columns = OrganizationSchema.$columns diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts index 42e0531..3f41692 100644 --- a/apps/api/database/schema_rules.ts +++ b/apps/api/database/schema_rules.ts @@ -13,5 +13,13 @@ export default { }, }, }, + invoices: { + columns: { + status: { + tsType: + "'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'paid' | 'litigation' | 'cancelled'", + }, + }, + }, }, } satisfies SchemaRules diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 3b965bd..75e5552 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -82,5 +82,24 @@ router .prefix('plans') .as('plans') .use(middleware.auth()) + + /** + * Invoices — auth requise. Note : /counts doit être déclaré AVANT + * /:id (sinon `:id` matche "counts" et le param.id devient la string). + */ + router + .group(() => { + router.get('', [controllers.Invoices, 'index']).as('index') + router.post('', [controllers.Invoices, 'store']).as('store') + router.get('counts', [controllers.Invoices, 'counts']).as('counts') + router.get(':id', [controllers.Invoices, 'show']).as('show').where('id', router.matchers.uuid()) + router + .post(':id/mark-paid', [controllers.Invoices, 'markPaid']) + .as('mark-paid') + .where('id', router.matchers.uuid()) + }) + .prefix('invoices') + .as('invoices') + .use(middleware.auth()) }) .prefix('/api/v1')