rubis/apps/api/app/validators/invoice.ts
ordinarthur 005af557c2 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.
2026-05-06 14:33:46 +02:00

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(),
})