feat(api): domaine Invoice + endpoints CRUD + branche stats Client/Plan
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.
This commit is contained in:
parent
692b514fe9
commit
005af557c2
392
apps/api/app/controllers/invoices_controller.ts
Normal file
392
apps/api/app/controllers/invoices_controller.ts
Normal file
@ -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<string, number> = {
|
||||||
|
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<Client | { errorCode: 'client_email_required' }> {
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,8 @@ import type { HttpContext } from '@adonisjs/core/http'
|
|||||||
import { Exception } from '@adonisjs/core/exceptions'
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
|
||||||
|
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
|
||||||
|
|
||||||
function requireOrgId(auth: HttpContext['auth']): string {
|
function requireOrgId(auth: HttpContext['auth']): string {
|
||||||
const user = auth.getUserOrFail()
|
const user = auth.getUserOrFail()
|
||||||
if (!user.organizationId) {
|
if (!user.organizationId) {
|
||||||
@ -19,18 +21,31 @@ function serializePlan(p: Plan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compte combien de factures actives référencent chaque plan d'une org.
|
* Compte combien de factures actives (non payées, non annulées) référencent
|
||||||
* Utilisé pour enrichir la liste avec un badge d'usage.
|
* 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.
|
||||||
* @todo Brancher sur Invoice quand le domaine arrive — pour l'instant 0
|
|
||||||
* partout (le contrat reste stable côté SPA).
|
|
||||||
*/
|
*/
|
||||||
async function bulkComputePlanUsage(
|
async function bulkComputePlanUsage(
|
||||||
_organizationId: string,
|
organizationId: string,
|
||||||
planIds: string[]
|
planIds: string[]
|
||||||
): Promise<Map<string, number>> {
|
): Promise<Map<string, number>> {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
for (const id of planIds) map.set(id, 0)
|
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
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { ClientSchema } from '#database/schema'
|
import { ClientSchema } from '#database/schema'
|
||||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
|
||||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
||||||
import Organization from '#models/organization'
|
import Organization from '#models/organization'
|
||||||
|
import Invoice from '#models/invoice'
|
||||||
|
|
||||||
export default class Client extends ClientSchema {
|
export default class Client extends ClientSchema {
|
||||||
@belongsTo(() => Organization)
|
@belongsTo(() => Organization)
|
||||||
declare organization: BelongsTo<typeof Organization>
|
declare organization: BelongsTo<typeof Organization>
|
||||||
|
|
||||||
// hasMany Invoice — sera ajouté quand le domaine Invoice arrivera.
|
@hasMany(() => Invoice)
|
||||||
|
declare invoices: HasMany<typeof Invoice>
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/api/app/models/invoice.ts
Normal file
17
apps/api/app/models/invoice.ts
Normal file
@ -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<typeof Organization>
|
||||||
|
|
||||||
|
@belongsTo(() => Client)
|
||||||
|
declare client: BelongsTo<typeof Client>
|
||||||
|
|
||||||
|
@belongsTo(() => Plan)
|
||||||
|
declare plan: BelongsTo<typeof Plan>
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
|
|||||||
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
||||||
import Organization from '#models/organization'
|
import Organization from '#models/organization'
|
||||||
import PlanStep from '#models/plan_step'
|
import PlanStep from '#models/plan_step'
|
||||||
|
import Invoice from '#models/invoice'
|
||||||
|
|
||||||
export default class Plan extends PlanSchema {
|
export default class Plan extends PlanSchema {
|
||||||
@belongsTo(() => Organization)
|
@belongsTo(() => Organization)
|
||||||
@ -10,4 +11,7 @@ export default class Plan extends PlanSchema {
|
|||||||
|
|
||||||
@hasMany(() => PlanStep, { foreignKey: 'planId' })
|
@hasMany(() => PlanStep, { foreignKey: 'planId' })
|
||||||
declare steps: HasMany<typeof PlanStep>
|
declare steps: HasMany<typeof PlanStep>
|
||||||
|
|
||||||
|
@hasMany(() => Invoice)
|
||||||
|
declare invoices: HasMany<typeof Invoice>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
|
||||||
* (V1 : pas de cache, le volume reste raisonnable).
|
* (V1 : pas de cache, le volume reste raisonnable). Si le perf devient un
|
||||||
*
|
* sujet, on cachera dans Redis avec invalidation post-mutation invoice.
|
||||||
* Tant que le domaine Invoice n'est pas câblé, on retourne EMPTY pour
|
|
||||||
* tous les clients — le contrat reste stable côté SPA.
|
|
||||||
*/
|
*/
|
||||||
export type ClientStats = {
|
export type ClientStats = {
|
||||||
invoiceCount: number
|
invoiceCount: number
|
||||||
@ -26,18 +26,66 @@ export const EMPTY_CLIENT_STATS: ClientStats = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule les stats pour un ensemble de clients d'une org.
|
* Calcule les stats pour un ensemble de clients d'une org en une seule
|
||||||
* @returns Map clientId → ClientStats
|
* 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(
|
export async function bulkComputeClientStats(
|
||||||
_organizationId: string,
|
organizationId: string,
|
||||||
clientIds: string[]
|
clientIds: string[]
|
||||||
): Promise<Map<string, ClientStats>> {
|
): Promise<Map<string, ClientStats>> {
|
||||||
const map = new Map<string, ClientStats>()
|
const map = new Map<string, ClientStats>()
|
||||||
for (const id of clientIds) {
|
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
|
return map
|
||||||
}
|
}
|
||||||
|
|||||||
30
apps/api/app/transformers/invoice_transformer.ts
Normal file
30
apps/api/app/transformers/invoice_transformer.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type Invoice from '#models/invoice'
|
||||||
|
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||||
|
|
||||||
|
export default class InvoiceTransformer extends BaseTransformer<Invoice> {
|
||||||
|
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()!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/api/app/validators/invoice.ts
Normal file
42
apps/api/app/validators/invoice.ts
Normal file
@ -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(),
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -57,6 +57,41 @@ export class ClientSchema extends BaseModel {
|
|||||||
declare updatedAt: DateTime | null
|
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 {
|
export class OrganizationSchema extends BaseModel {
|
||||||
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
|
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
|
||||||
$columns = OrganizationSchema.$columns
|
$columns = OrganizationSchema.$columns
|
||||||
|
|||||||
@ -13,5 +13,13 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
columns: {
|
||||||
|
status: {
|
||||||
|
tsType:
|
||||||
|
"'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'paid' | 'litigation' | 'cancelled'",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} satisfies SchemaRules
|
} satisfies SchemaRules
|
||||||
|
|||||||
@ -82,5 +82,24 @@ router
|
|||||||
.prefix('plans')
|
.prefix('plans')
|
||||||
.as('plans')
|
.as('plans')
|
||||||
.use(middleware.auth())
|
.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')
|
.prefix('/api/v1')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user