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(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>; } { 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 = [ { 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 = { 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 };