rubis/apps/web/src/mocks/handlers/invoices.ts
ordinarthur 16120ed3e0 feat(web): création client (modale) + email required + SIRET optionnel
Réflexion produit : email required vs optionnel.

Le coeur de Rubis = relances email automatiques. Sans email client →
aucune relance ne peut partir → la fiche client est inutilisable pour
le coeur du produit. Décision : email REQUIRED partout, plutôt que
laisser créer des fiches mortes.

Type Client (packages/shared) :
- email: string (était string | null)
- siret: string | null ajouté (optionnel mais recommandé pour mises
  en demeure formelles + intégrations comptables V2 type Pennylane)

ClientCreateDialog (modale "+ Nouveau client" sur /clients) :
- Email required avec validator Zod min(1).email()
- SIRET ajouté côte-à-côte avec Téléphone (validator 14 chiffres
  ou vide, inputMode='numeric', espaces tolérés à la frappe)
- Adresse postale déplacée full-width (lisibilité)
- Hints éducatifs : 'Préférez compta@/facturation@ à une nominative',
  'Recommandé pour les mises en demeure', 'Requise pour les mises en
  demeure formelles'

Field component aligned :
- Label/hint en haut, input en bas (mt-auto sur le wrapper input)
- Quand 2 Fields sont côte-à-côte avec hints de longueur différente,
  les inputs restent alignés au bas — le hint plus long étire le haut
- Erreur reste collée sous l'input (pas en bas de la cellule)

MSW :
- POST /clients schema strict : email required, siret 14 chiffres si fourni
- Détection doublon par nom (409) conservée
- Handlers création de client implicites (saisie facture, OCR review)
  refusent maintenant la création quand email manquant : 422 ciblé
  'Email du client requis — Rubis en a besoin pour envoyer les relances.'
  Si l'user pick un client existant via le combobox → email déjà en
  DB, pas demandé.

Migration mockDb :
- Anciens clients sans siret → null
- Anciens clients avec email null (cas test) → placeholder dérivé du
  slug du nom (contact@boulangerie-martin.fr) — éditable, juste évite
  un crash au load. slugifyClientName() supprime SARL/SAS/EURL et accents.

Détail /clients/$id :
- SIRET ajouté dans la barre meta du header (Hash icon Lucide +
  tabular-nums) — affiché seulement si rempli
- Email plus conditionnel (toujours présent maintenant)

Seeds :
- Boulangerie Martin SARL : SIRET 82345678900012
- Cabinet Rousseau : SIRET 53412987600028
- Atelier Durand, Garage Lemoine, Studio Lefèvre : siret null
  (pour tester les deux cas dans la liste)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:25:37 +02:00

636 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { http, HttpResponse } from "msw";
import { z } from "zod";
import { invoiceListFiltersSchema } from "@rubis/shared";
import type { Client, InvoiceStatus, Plan, PlanStep } from "@rubis/shared";
import { mockDb, type DraftFields, type StoredInvoice } from "../db";
import { userIdFromAuthHeader } from "./auth";
const apiBase = "*/api/v1";
function unauthenticated() {
return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 },
);
}
function notFound() {
return HttpResponse.json(
{ errors: [{ code: "not_found", message: "Facture introuvable" }] },
{ status: 404 },
);
}
function authedOrgId(authHeader: string | null): string | undefined {
const userId = userIdFromAuthHeader(authHeader);
if (!userId) return undefined;
return mockDb.findUserById(userId)?.organizationId;
}
/* ----------------------------------------------------------------------------
* OCR mock — extraction "intelligente" depuis un nom de fichier.
* Pour faire vivre la démo, on génère des champs plausibles et on injecte
* volontairement quelques confidences basses pour signaler "champ douteux".
* -------------------------------------------------------------------------- */
const SAMPLE_CLIENT_NAMES = [
"Boulangerie Martin SARL",
"Atelier Durand",
"Cabinet Rousseau",
"Garage Lemoine",
"Studio Lefèvre",
"Pharmacie Bertrand",
"Imprimerie Moreau",
];
function rand<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function randomAmountCents(): number {
// entre 80 € et 8 000 €, multiple de 50 cents pour rester crédible
return Math.floor((Math.random() * 7920 + 80) * 100 / 50) * 50;
}
function randomNumeroFromFilename(filename: string): string {
const match = filename.match(/(\d{2,5})/u);
const yr = new Date().getFullYear();
const seq = match?.[1] ?? Math.floor(Math.random() * 9000 + 1000).toString();
return `F-${yr}-${seq.padStart(4, "0")}`;
}
function isoDaysFromNow(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(9, 0, 0, 0);
return d.toISOString();
}
function fakeOcrExtract(
orgId: string,
filename: string,
defaultPlanId: string | null,
): {
extracted: DraftFields;
confidence: Partial<Record<keyof DraftFields, number>>;
} {
const clientName = rand(SAMPLE_CLIENT_NAMES);
// 30 % de chance d'avoir un email douteux (low confidence)
const emailLowConf = Math.random() < 0.3;
const slug = clientName
.toLowerCase()
.replace(/sarl|sa|sas/giu, "")
.replace(/[^a-z]+/giu, "-")
.replace(/^-+|-+$/gu, "");
// Si l'OCR a "extrait" un nom qui matche un client existant, on lie tout
// de suite au clientId : l'utilisateur voit son client déjà sélectionné
// dans le combobox et n'a rien à faire.
const matchedClient = mockDb
.listClientsForOrg(orgId)
.find((c) => c.name.toLowerCase() === clientName.toLowerCase());
return {
extracted: {
clientId: matchedClient?.id ?? null,
clientName: matchedClient?.name ?? clientName,
clientEmail: matchedClient?.email ?? (emailLowConf ? null : `compta@${slug}.fr`),
numero: randomNumeroFromFilename(filename),
amountTtcCents: randomAmountCents(),
issueDate: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
dueDate: isoDaysFromNow(15 + Math.floor(Math.random() * 20)),
planId: defaultPlanId,
},
confidence: {
clientName: matchedClient ? 1 : 0.95,
clientEmail: emailLowConf ? 0.42 : 0.88,
numero: 0.97,
amountTtcCents: 0.93,
issueDate: 0.9,
dueDate: emailLowConf ? 0.65 : 0.92,
planId: 1,
},
};
}
const uploadSchema = z.object({
filenames: z.array(z.string().min(1)).min(1).max(20),
});
const draftFieldsSchema = z.object({
clientId: z.string().nullable(),
clientName: z.string().min(1).max(120),
clientEmail: z.string().email().nullable(),
numero: z.string().min(1).max(50),
amountTtcCents: z.number().int().positive(),
issueDate: z.string().datetime(),
dueDate: z.string().datetime(),
planId: z.string().nullable(),
});
const createInvoiceManualSchema = z.object({
clientId: z.string().optional(),
clientName: z.string().min(2).max(120),
clientEmail: z.string().email().nullable().optional(),
numero: z.string().min(1).max(50),
amountTtcCents: z.number().int().positive(),
issueDate: z.string().datetime(),
dueDate: z.string().datetime(),
planId: z.string().nullable().optional(),
});
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant. Très simplifié pour V1 :
* - étapes dont sendDay <= aujourd'hui : "past" (envoyées)
* - étape actuelle (la prochaine future) : "current"
* - étapes futures : "future"
*
* On ajoute un événement initial "facture émise".
*/
function buildTimeline(
invoice: StoredInvoice,
plan: Plan | null,
): Array<{
id: string;
state: "past" | "current" | "future";
when: string;
what: string;
detail?: string;
}> {
const events: ReturnType<typeof buildTimeline> = [
{
id: `${invoice.id}__issued`,
state: "past",
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
what: "Importée · OCR validée",
},
];
if (plan && invoice.status !== "paid" && invoice.status !== "cancelled") {
const dueMs = new Date(invoice.dueDate).getTime();
const nowMs = Date.now();
let currentSet = false;
for (const step of plan.steps) {
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000;
const sendDate = new Date(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(sendDate.toISOString())} · ${labelStep}`,
what:
state === "past"
? `Email envoyé · "${step.subject.replace("{{numero}}", invoice.numero)}"`
: state === "current"
? `Email programmé · "${step.subject.replace("{{numero}}", invoice.numero)}"`
: `Email programmé · "${step.subject.replace("{{numero}}", invoice.numero)}"`,
detail: state === "past" ? "Ouvert 1×" : undefined,
});
}
}
if (invoice.status === "paid") {
events.push({
id: `${invoice.id}__paid`,
state: "past",
when: `${formatShortDate(invoice.updatedAt)} · facture encaissée`,
what: "Marquée encaissée — relances stoppées",
});
}
return events;
}
function formatShortDate(iso: string): string {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1)
.toString()
.padStart(2, "0")}/${d.getFullYear()}`;
}
export const invoiceHandlers = [
// GET /api/v1/invoices?status=&q=&clientId=&page=
http.get(`${apiBase}/invoices`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const parsed = invoiceListFiltersSchema.safeParse({
...params,
page: params.page ? Number(params.page) : undefined,
});
let invoices = mockDb.listInvoicesForOrg(orgId);
const filters = parsed.success ? parsed.data : { page: 1 };
if (filters.status && filters.status !== "all") {
invoices = invoices.filter((i) => i.status === (filters.status as InvoiceStatus));
}
if (filters.clientId) {
invoices = invoices.filter((i) => i.clientId === filters.clientId);
}
if (filters.q) {
const q = filters.q.toLowerCase();
invoices = invoices.filter(
(i) =>
i.numero.toLowerCase().includes(q) ||
i.clientName.toLowerCase().includes(q),
);
}
// Tri : à relancer / en relance d'abord (les plus actionnables), puis par échéance
const STATUS_PRIO: Record<InvoiceStatus, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
};
invoices = [...invoices].sort((a, b) => {
const dp = STATUS_PRIO[a.status] - STATUS_PRIO[b.status];
if (dp !== 0) return dp;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
});
return HttpResponse.json({
data: invoices,
meta: {
total: invoices.length,
page: filters.page ?? 1,
},
});
}),
// GET /api/v1/invoices/counts — compteurs par statut pour les chips
http.get(`${apiBase}/invoices/counts`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const invoices = mockDb.listInvoicesForOrg(orgId);
const counts = {
all: invoices.length,
pending: invoices.filter((i) => i.status === "pending").length,
in_relance: invoices.filter((i) => i.status === "in_relance").length,
awaiting_user_confirmation: invoices.filter(
(i) => i.status === "awaiting_user_confirmation",
).length,
paid: invoices.filter((i) => i.status === "paid").length,
litigation: invoices.filter((i) => i.status === "litigation").length,
cancelled: invoices.filter((i) => i.status === "cancelled").length,
};
return HttpResponse.json({ data: counts });
}),
// GET /api/v1/invoices/:id — détail enrichi (client + plan + timeline)
http.get(`${apiBase}/invoices/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const id = params.id as string;
const invoice = mockDb.findInvoiceById(orgId, id);
if (!invoice) return notFound();
const client = mockDb.findClientById(orgId, invoice.clientId) as Client;
const plan = invoice.planId ? mockDb.findPlanById(orgId, invoice.planId) ?? null : null;
const timeline = buildTimeline(invoice, plan);
return HttpResponse.json({
data: {
...invoice,
client,
plan,
timeline,
},
});
}),
// GET /api/v1/clients — autocomplete dans la saisie manuelle
http.get(`${apiBase}/clients`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
return HttpResponse.json({ data: mockDb.listClientsForOrg(orgId) });
}),
// POST /api/v1/invoices — saisie manuelle (cf. wireframe 2.3)
http.post(`${apiBase}/invoices`, async ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = createInvoiceManualSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
const fields = parsed.data;
// Résolution client : si clientId fourni → utilise. Sinon match par nom
// (case-insensitive). Sinon crée un nouveau client à la volée — mais
// dans ce cas l'email est OBLIGATOIRE car sans email Rubis ne peut
// pas envoyer les relances (cf. décision produit).
let clientId = fields.clientId;
let clientName = fields.clientName;
if (!clientId) {
const existing = mockDb
.listClientsForOrg(orgId)
.find(
(c) => c.name.toLowerCase() === fields.clientName.toLowerCase(),
);
if (existing) {
clientId = existing.id;
clientName = existing.name;
} else {
if (!fields.clientEmail || fields.clientEmail.length === 0) {
return HttpResponse.json(
{
errors: [
{
code: "client_email_required",
message:
"Email du client requis pour créer une nouvelle fiche — Rubis en a besoin pour envoyer les relances.",
field: "clientEmail",
},
],
},
{ status: 422 },
);
}
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
siret: null,
notes: null,
});
clientId = created.id;
clientName = created.name;
}
} else {
const c = mockDb.findClientById(orgId, clientId);
if (c) clientName = c.name;
}
const plan = fields.planId ? mockDb.findPlanById(orgId, fields.planId) : null;
const invoice = mockDb.createInvoice(orgId, {
clientId: clientId!,
clientName,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
status: "pending",
planId: plan?.id ?? null,
planName: plan?.name ?? null,
pdfStorageKey: null,
notes: null,
rubisEarned: 1, // bonus saisie
});
return HttpResponse.json({ data: invoice }, { status: 201 });
}),
// POST /api/v1/invoices/upload — démarre un batch OCR
http.post(`${apiBase}/invoices/upload`, async ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = uploadSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
})),
},
{ status: 422 },
);
}
// Plan par défaut = le premier "isDefault" disponible
const plans = mockDb.listPlansForOrg(orgId);
const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null;
const drafts = parsed.data.filenames.map((filename) => {
const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId);
return { filename, extracted, confidence };
});
const batch = mockDb.createImportBatch(orgId, drafts);
// Petit délai pour simuler le temps de l'OCR (perceptible UX-wise)
await new Promise((r) => setTimeout(r, 800));
return HttpResponse.json({ data: batch }, { status: 201 });
}),
// GET /api/v1/invoices/import-batch/:id
http.get(`${apiBase}/invoices/import-batch/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const batch = mockDb.findImportBatch(orgId, params.id as string);
if (!batch) return notFound();
return HttpResponse.json({ data: batch });
}),
// POST /api/v1/invoices/import-batch/:id/drafts/:draftId/validate
http.post(
`${apiBase}/invoices/import-batch/:id/drafts/:draftId/validate`,
async ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = draftFieldsSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
const fields = parsed.data;
// Résolution client (priorité descendante) :
// 1. clientId explicite envoyé depuis le combobox → utilise direct
// 2. match par nom (case-insensitive) sur les clients existants
// 3. création à la volée si rien ne matche — email obligatoire
// puisqu'on n'accepte pas de fiche client sans canal de relance.
let clientId: string;
let clientName: string;
const requireEmailForCreation = () => {
if (!fields.clientEmail || fields.clientEmail.length === 0) {
return HttpResponse.json(
{
errors: [
{
code: "client_email_required",
message:
"Email du client requis — Rubis en a besoin pour envoyer les relances.",
field: "clientEmail",
},
],
},
{ status: 422 },
);
}
return null;
};
if (fields.clientId) {
const c = mockDb.findClientById(orgId, fields.clientId);
if (c) {
clientId = c.id;
clientName = c.name;
} else {
// clientId fourni mais introuvable — fallback création
const err = requireEmailForCreation();
if (err) return err;
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail!,
phone: null,
address: null,
siret: null,
notes: null,
});
clientId = created.id;
clientName = created.name;
}
} else {
const matched = mockDb
.listClientsForOrg(orgId)
.find(
(c) => c.name.toLowerCase() === fields.clientName.toLowerCase(),
);
if (matched) {
clientId = matched.id;
clientName = matched.name;
} else {
const err = requireEmailForCreation();
if (err) return err;
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail!,
phone: null,
address: null,
siret: null,
notes: null,
});
clientId = created.id;
clientName = created.name;
}
}
const plan = fields.planId
? mockDb.findPlanById(orgId, fields.planId)
: null;
const invoice = mockDb.createInvoice(orgId, {
clientId,
clientName,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
status: "pending",
planId: plan?.id ?? null,
planName: plan?.name ?? null,
pdfStorageKey: null,
notes: null,
rubisEarned: 1, // bonus rubis : 1 import = 1 rubis
});
mockDb.updateImportDraft(orgId, params.id as string, params.draftId as string, {
status: "validated",
edited: fields,
invoiceId: invoice.id,
});
return HttpResponse.json({ data: invoice }, { status: 201 });
},
),
// POST /api/v1/invoices/import-batch/:id/drafts/:draftId/skip
http.post(
`${apiBase}/invoices/import-batch/:id/drafts/:draftId/skip`,
({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const draft = mockDb.updateImportDraft(
orgId,
params.id as string,
params.draftId as string,
{ status: "skipped" },
);
if (!draft) return notFound();
return HttpResponse.json({ data: draft });
},
),
// DELETE /api/v1/invoices/import-batch/:id — annule le batch entier
http.delete(`${apiBase}/invoices/import-batch/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const ok = mockDb.deleteImportBatch(orgId, params.id as string);
if (!ok) return notFound();
return new HttpResponse(null, { status: 204 });
}),
// POST /api/v1/invoices/:id/mark-paid
http.post(`${apiBase}/invoices/:id/mark-paid`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const id = params.id as string;
const invoice = mockDb.findInvoiceById(orgId, id);
if (!invoice) return notFound();
const updated = mockDb.updateInvoice(orgId, id, {
status: "paid",
// +1 rubis bonus quand on encaisse — gamification
rubisEarned: invoice.rubisEarned + 1,
});
return HttpResponse.json({ data: updated });
}),
];
// Suppress unused warnings for the imported types we re-export indirectly.
export type { PlanStep };