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.
43 lines
1.4 KiB
TypeScript
43 lines
1.4 KiB
TypeScript
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(),
|
|
})
|