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>
636 lines
20 KiB
TypeScript
636 lines
20 KiB
TypeScript
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 };
|