/** * Factories — créent des entités réalistes (FR, format Rubis) pour * peupler une org de démo ou alimenter des tests. * * Pas de framework lourd type @adonisjs/lucid factories : des fonctions * pures, idempotentes, qu'on compose dans une commande Ace ou un test. */ import { DateTime } from 'luxon' import { randomUUID } from 'node:crypto' import { readdir, readFile } from 'node:fs/promises' import { join } from 'node:path' import { existsSync } from 'node:fs' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import drive from '@adonisjs/drive/services/main' import Client from '#models/client' import Invoice from '#models/invoice' import ActivityEvent from '#models/activity_event' import RelanceTask from '#models/relance_task' import Plan from '#models/plan' // --------------------------------------------------------------------------- // Sources de données déterministes (pas de Faker — moins de deps, plus stable) // --------------------------------------------------------------------------- const CLIENT_TEMPLATES: Array<{ name: string firstName: string | null lastName: string | null emailDomain: string phone: string | null address: string | null siret: string | null }> = [ { name: 'Boulangerie Martin SARL', firstName: 'Marie', lastName: 'Martin', emailDomain: 'boulangerie-martin.fr', phone: '+33 1 23 45 67 89', address: '12 rue du Pain, 75011 Paris', siret: '82345678900012', }, { name: 'Maçonnerie Dupont & Fils', firstName: 'Jean', lastName: 'Dupont', emailDomain: 'maconnerie-dupont.fr', phone: '+33 4 78 56 12 34', address: '45 chemin des Carrières, 69100 Villeurbanne', siret: '53412987600028', }, { name: 'Atelier Durand', firstName: null, lastName: null, emailDomain: 'atelier-durand.fr', phone: null, address: null, siret: null, }, { name: 'Cabinet Rousseau Conseil', firstName: 'Julien', lastName: 'Rousseau', emailDomain: 'cabinet-rousseau.fr', phone: '+33 4 56 78 90 12', address: '8 place de la République, 69002 Lyon', siret: '53412987600101', }, { name: 'Garage Lemoine', firstName: 'Pierre', lastName: 'Lemoine', emailDomain: 'garage-lemoine.fr', phone: '+33 2 99 87 65 43', address: '23 boulevard de la Liberté, 35000 Rennes', siret: '78912345600054', }, { name: 'Studio Lefèvre', firstName: 'Camille', lastName: 'Lefèvre', emailDomain: 'studio-lefevre.com', phone: null, address: null, siret: null, }, { name: 'Restaurant Le Beauvoir', firstName: 'Sophie', lastName: 'Beauvoir', emailDomain: 'le-beauvoir.fr', phone: '+33 1 45 22 33 44', address: '15 rue Mouffetard, 75005 Paris', siret: '12345678900078', }, { name: 'Imprimerie Henri & Fils', firstName: 'Henri', lastName: 'Petit', emailDomain: 'imprimerie-henri.fr', phone: '+33 5 61 78 90 23', address: '7 avenue Jean Jaurès, 31000 Toulouse', siret: '45123789600041', }, ] const INVOICE_NOTES_POOL = [ 'Prestation conseil — janvier', 'Travaux de rénovation second œuvre', 'Photographe événementiel — mariage', 'Maintenance trimestrielle', 'Livraison matières premières', 'Audit comptable annuel', null, null, ] // --------------------------------------------------------------------------- // Factories // --------------------------------------------------------------------------- export type ClientFactoryInput = { organizationId: string trx?: TransactionClientContract /** Index 0..N dans CLIENT_TEMPLATES, sinon valeurs random. */ index?: number } export async function makeClient(input: ClientFactoryInput): Promise { const tpl = input.index !== undefined ? CLIENT_TEMPLATES[input.index % CLIENT_TEMPLATES.length]! : CLIENT_TEMPLATES[Math.floor(Math.random() * CLIENT_TEMPLATES.length)]! const inboxName = tpl.firstName ? `${tpl.firstName.toLowerCase()}@${tpl.emailDomain}` : `compta@${tpl.emailDomain}` return Client.create( { organizationId: input.organizationId, name: tpl.name, email: inboxName, contactFirstName: tpl.firstName, contactLastName: tpl.lastName, phone: tpl.phone, address: tpl.address, siret: tpl.siret, notes: null, }, { client: input.trx } ) } export type InvoiceStatus = Invoice['status'] export type InvoiceFactoryInput = { organizationId: string clientId: string planId: string | null /** Status cible — drive aussi les dates (pending = future, paid = passé). */ status: InvoiceStatus /** Numéro override, sinon F-YYYY-XXXX random. */ numero?: string /** Montant en centimes. Sinon random 250€-8000€. */ amountTtcCents?: number /** Décalage en jours par rapport à aujourd'hui pour issueDate. * Négatif = passé. */ issueOffsetDays?: number /** Délai de paiement en jours (par défaut 30, conforme LME). */ paymentTermDays?: number trx?: TransactionClientContract } export async function makeInvoice(input: InvoiceFactoryInput): Promise { const issueOffset = input.issueOffsetDays ?? -randomInt(7, 90) const paymentTerm = input.paymentTermDays ?? 30 const issueDate = DateTime.utc().plus({ days: issueOffset }).startOf('day') const dueDate = issueDate.plus({ days: paymentTerm }) const amount = input.amountTtcCents ?? randomInt(25_000, 800_000) const numero = input.numero ?? `F-${issueDate.year}-${String(randomInt(1, 9999)).padStart(4, '0')}` // paidAt : pour les statuts paid, on simule un paiement après l'échéance // (parfois en avance, parfois en retard — réaliste). let paidAt: DateTime | null = null if (input.status === 'paid') { const paidOffset = randomInt(-5, 25) // -5 = payé en avance, +25 = en retard paidAt = dueDate.plus({ days: paidOffset }) } // rubisEarned : 1 par relance envoyée + 1 si payée let rubisEarned = 0 if (input.status === 'in_relance' || input.status === 'awaiting_user_confirmation') { rubisEarned = randomInt(1, 3) } else if (input.status === 'paid') { rubisEarned = randomInt(0, 4) // certaines payées sans relance } return Invoice.create( { organizationId: input.organizationId, clientId: input.clientId, planId: input.planId, numero, amountTtcCents: amount, issueDate, dueDate, paidAt, status: input.status, pdfStorageKey: null, rubisEarned, notes: pickRandom(INVOICE_NOTES_POOL), }, { client: input.trx } ) } export type ActivityFactoryInput = { organizationId: string invoice: Invoice client: Client trx?: TransactionClientContract } /** * Pour chaque facture, génère les events réalistes : * - invoice_imported (toujours, à issueDate) * - relance_sent N fois si status in_relance/awaiting (entre issueDate et now) * - invoice_paid si status paid */ export async function makeActivityForInvoice( input: ActivityFactoryInput ): Promise { const { invoice, client, trx } = input // Import — toujours await ActivityEvent.create( { organizationId: input.organizationId, kind: 'invoice_imported', at: invoice.issueDate, label: `Facture ${invoice.numero} importée`, meta: { invoiceId: invoice.id, clientId: client.id }, }, { client: trx } ) if (invoice.status === 'in_relance' || invoice.status === 'awaiting_user_confirmation') { const relanceCount = randomInt(1, 3) for (let i = 0; i < relanceCount; i++) { const sentAt = invoice.dueDate.plus({ days: 3 + i * 7 }) if (sentAt > DateTime.utc()) break await ActivityEvent.create( { organizationId: input.organizationId, kind: 'relance_sent', at: sentAt, label: `Relance J+${3 + i * 7} envoyée à ${client.name}`, meta: { invoiceId: invoice.id, clientId: client.id, planStepOrder: i, }, }, { client: trx } ) } } if (invoice.status === 'paid' && invoice.paidAt) { await ActivityEvent.create( { organizationId: input.organizationId, kind: 'invoice_paid', at: invoice.paidAt, label: `${client.name} a réglé ${invoice.numero}`, meta: { invoiceId: invoice.id, clientId: client.id }, }, { client: trx } ) } } // --------------------------------------------------------------------------- // Recettes — combine les factories pour produire une org démo cohérente // --------------------------------------------------------------------------- export type DemoSeedConfig = { organizationId: string /** Plans déjà provisionnés dans l'org (pour piocher des planId). */ plans: Plan[] trx: TransactionClientContract /** Combien de clients (1..N templates dispo). Défaut 8. */ clientCount?: number } export type DemoSeedResult = { clients: Client[] invoices: Invoice[] rubisEarned: number } /** * Mix de statuts représentatif d'une TPE active : * - 5 paid (DSO calc + encaissé total) * - 4 in_relance (à voir dans le funnel "en relance") * - 2 awaiting_user_confirmation (check-in en attente) * - 3 pending (récentes, pas encore relancées) * - 1 litigation (cas tendu) */ const INVOICE_RECIPE: Array<{ status: InvoiceStatus; issueOffsetDays: number; planIdx?: number }> = [ // Paid — réparties sur 6 mois pour faire vivre le DSO { status: 'paid', issueOffsetDays: -180 }, { status: 'paid', issueOffsetDays: -135 }, { status: 'paid', issueOffsetDays: -95 }, { status: 'paid', issueOffsetDays: -65 }, { status: 'paid', issueOffsetDays: -40 }, // En relance — échéances passées récentes { status: 'in_relance', issueOffsetDays: -55 }, { status: 'in_relance', issueOffsetDays: -50 }, { status: 'in_relance', issueOffsetDays: -42 }, { status: 'in_relance', issueOffsetDays: -38 }, // Awaiting check-in — échéance toute fraîche { status: 'awaiting_user_confirmation', issueOffsetDays: -32 }, { status: 'awaiting_user_confirmation', issueOffsetDays: -30 }, // Pending — récentes, pas encore arrivées à échéance { status: 'pending', issueOffsetDays: -10 }, { status: 'pending', issueOffsetDays: -5 }, { status: 'pending', issueOffsetDays: -2 }, // Litigation — ancienne, contestée { status: 'litigation', issueOffsetDays: -90 }, ] /** CA cible sur 12 mois pour le seed démo (en centimes). */ const TARGET_ANNUAL_REVENUE_CENTS = 40_000_000 // 400 000 € /** Nombre de factures historiques à générer (paid sur les 12 derniers mois). */ const HISTORICAL_INVOICE_COUNT = 200 export async function seedDemoOrg(config: DemoSeedConfig): Promise { const { organizationId, plans, trx } = config // Phase 1 — Actionnables : si on a des PDFs réels dans assets/test-invoices, // on les utilise (mix pending/in_relance/paid/litigation, avec preview PDF). // Sinon on tombe sur la recipe synthétique. const assetsDir = resolveTestInvoicesDir() const actionable: DemoSeedResult = assetsDir ? await seedFromAssetPdfs({ ...config, assetsDir }) : await seedSyntheticActionable(config) // Phase 2 — Historique : ~200 factures `paid` réparties sur 12 mois pour // alimenter les graphes (encaissé mensuel, DSO, etc.) et donner un CA // annuel d'environ 400 K€. const paidInActionable = actionable.invoices .filter((inv) => inv.status === 'paid') .reduce((s, inv) => s + inv.amountTtcCents, 0) const historicalTarget = Math.max( TARGET_ANNUAL_REVENUE_CENTS - paidInActionable, 20_000_000 // garde-fou : au moins 200 K€ pour avoir des graphes lisibles ) const historical = await seedHistoricalInvoices({ organizationId, clients: actionable.clients, plans, trx, targetRevenueCents: historicalTarget, invoiceCount: HISTORICAL_INVOICE_COUNT, monthsBack: 12, }) return { clients: actionable.clients, invoices: [...actionable.invoices, ...historical.invoices], rubisEarned: actionable.rubisEarned + historical.rubisEarned, } } /** Recipe synthétique fallback — utilisée si pas de PDFs dans assets/. */ async function seedSyntheticActionable( config: DemoSeedConfig ): Promise { const { organizationId, plans, trx } = config const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length) const clients: Client[] = [] for (let i = 0; i < clientCount; i++) { clients.push(await makeClient({ organizationId, index: i, trx })) } const invoices: Invoice[] = [] for (const [i, recipe] of INVOICE_RECIPE.entries()) { const client = clients[i % clients.length]! const plan = plans[i % plans.length] ?? null const invoice = await makeInvoice({ organizationId, clientId: client.id, planId: plan?.id ?? null, status: recipe.status, issueOffsetDays: recipe.issueOffsetDays, trx, }) invoices.push(invoice) await makeActivityForInvoice({ organizationId, invoice, client, trx }) } const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) return { clients, invoices, rubisEarned } } // --------------------------------------------------------------------------- // Recette "PDFs réels" — utilise assets/test-invoices/*.pdf // --------------------------------------------------------------------------- /** * Localise le dossier `assets/test-invoices`. La résolution couvre 3 contextes : * - dev local : cwd = `apps/api/` → `../../assets/test-invoices` * - prod (init) : cwd = `/app/apps/api` → `../../assets/test-invoices` * - prod (build) : cwd = `/app/apps/api/build` → `../../../assets/test-invoices` */ function resolveTestInvoicesDir(): string | null { const candidates = [ join(process.cwd(), '..', '..', 'assets', 'test-invoices'), join(process.cwd(), '..', '..', '..', 'assets', 'test-invoices'), join(process.cwd(), 'assets', 'test-invoices'), ] for (const c of candidates) { if (existsSync(c)) return c } return null } /** * Mappe le suffixe descriptif d'un nom de fichier vers (status, dueOffset). * Filenames : * - facture-pas-en-retard-echeance-{N}j-XXX.pdf → due dans +N jours * - facture-echue-aujourdhui-XXX.pdf → due aujourd'hui * - facture-en-retard-{N}j-XXX.pdf → due il y a N jours * * Pour certaines factures très en retard, on simule un règlement tardif * (paid) ou une mise en demeure (litigation) — ça donne un mix réaliste. */ type AssetSpec = { filename: string /** Décalage de la dueDate par rapport à aujourd'hui (en jours). */ dueOffsetDays: number status: InvoiceStatus } /** Quelques numéros qu'on bascule en paid / litigation pour le mix démo. */ const PAID_OVERRIDES = new Set([ 'facture-en-retard-30j-017.pdf', 'facture-en-retard-45j-019.pdf', 'facture-en-retard-60j-022.pdf', 'facture-en-retard-90j-024.pdf', 'facture-en-retard-120j-026.pdf', ]) const LITIGATION_OVERRIDES = new Set([ 'facture-en-retard-120j-025.pdf', 'facture-en-retard-180j-027.pdf', ]) function parseAssetFilename(filename: string): AssetSpec | null { // Pattern 1 : pas-en-retard-echeance-{N}j → due in +N const futureMatch = filename.match(/pas-en-retard-echeance-(\d+)j/) if (futureMatch) { return { filename, dueOffsetDays: Number(futureMatch[1]), status: 'pending', } } // Pattern 2 : echue-aujourdhui if (/echue-aujourdhui/.test(filename)) { return { filename, dueOffsetDays: 0, status: 'awaiting_user_confirmation', } } // Pattern 3 : en-retard-{N}j → due -N const lateMatch = filename.match(/en-retard-(\d+)j/) if (lateMatch) { const days = Number(lateMatch[1]) let status: InvoiceStatus = 'in_relance' if (days <= 3) status = 'awaiting_user_confirmation' if (PAID_OVERRIDES.has(filename)) status = 'paid' else if (LITIGATION_OVERRIDES.has(filename)) status = 'litigation' return { filename, dueOffsetDays: -days, status, } } return null } /** Extrait le numéro depuis le filename (ex. `...-007.pdf` → `F2026-0007`). */ function deriveInvoiceNumero(filename: string, fallbackYear: number): string { const m = filename.match(/-(\d{3,})\.pdf$/) if (!m) return `F-${fallbackYear}-${String(randomInt(1, 9999)).padStart(4, '0')}` return `F${fallbackYear}-${m[1]!.padStart(4, '0')}` } /** * Crée les RelanceTask pour une invoice — uniquement pour les statuts qui ont * effectivement passé un check-in et donc déclenché des relances. Les statuts * `pending` et `awaiting_user_confirmation` n'en ont volontairement pas : tant * que l'user n'a pas répondu à un check-in, AUCUNE relance n'est programmée. * * - pending → 0 task (en attente du tout premier check-in) * - awaiting_user_confirmation → 0 task (check-in en cours, en attente) * - in_relance / litigation → sent si passée, scheduled sinon * - paid → sent jusqu'à paidAt, cancelled au-delà * * Pas d'enqueue BullMQ — sinon les jobs orphelins polluent Redis. */ async function seedRelanceTasksForInvoice( invoice: Invoice, plan: Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> }, trx: TransactionClientContract ): Promise { if (!plan.steps?.length) return // Statuts pré-check-in : aucune task à programmer. if (invoice.status === 'pending' || invoice.status === 'awaiting_user_confirmation') { return } const now = DateTime.utc() const paidAt = invoice.paidAt const sortedSteps = plan.steps.slice().sort((a, b) => a.order - b.order) for (const step of sortedSteps) { const sendAt = invoice.dueDate.plus({ days: step.offsetDays }) let status: 'scheduled' | 'sent' | 'cancelled' let sentAt: DateTime | null = null if (invoice.status === 'paid' && paidAt) { if (sendAt <= paidAt) { status = 'sent' sentAt = sendAt } else { status = 'cancelled' } } else { // in_relance, litigation if (sendAt <= now) { status = 'sent' sentAt = sendAt } else { status = 'scheduled' } } await RelanceTask.create( { organizationId: invoice.organizationId, invoiceId: invoice.id, planStepId: step.id, sendAt, status, sentAt, queueJobId: null, }, { client: trx } ) } } type AssetSeedConfig = DemoSeedConfig & { assetsDir: string } async function seedFromAssetPdfs(config: AssetSeedConfig): Promise { const { organizationId, plans, trx, assetsDir } = config const allFiles = await readdir(assetsDir) const pdfs = allFiles.filter((f) => f.endsWith('.pdf')).sort() const specs = pdfs .map(parseAssetFilename) .filter((s): s is AssetSpec => s !== null) if (specs.length === 0) { // Aucun PDF parseable : on n'aurait pas dû arriver ici. return { clients: [], invoices: [], rubisEarned: 0 } } // Charge les plans avec leurs steps — on en a besoin pour seeder les // RelanceTasks au bon `sendAt`. const plansWithSteps = await Plan.query({ client: trx }) .whereIn( 'id', plans.map((p) => p.id) ) .preload('steps', (q) => q.orderBy('order', 'asc')) // Crée tous les clients du pool — round-robin sur les factures. const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length) const clients: Client[] = [] for (let i = 0; i < clientCount; i++) { clients.push(await makeClient({ organizationId, index: i, trx })) } const invoices: Invoice[] = [] const today = DateTime.utc().startOf('day') for (const [i, spec] of specs.entries()) { const client = clients[i % clients.length]! const plan = plansWithSteps[i % plansWithSteps.length] ?? null // Upload le PDF vers le drive (MinIO en S3, fs en fallback). const filePath = join(assetsDir, spec.filename) const buffer = await readFile(filePath) const storageKey = `invoice-pdfs/${organizationId}/${randomUUID()}.pdf` await drive.use().put(storageKey, buffer) // Dates : on cale la dueDate sur l'offset, on assume 30j de termes. const dueDate = today.plus({ days: spec.dueOffsetDays }) const issueDate = dueDate.minus({ days: 30 }) let paidAt: DateTime | null = null if (spec.status === 'paid') { // Règlement après l'échéance — entre 5 et 30j de retard côté facture. paidAt = dueDate.plus({ days: randomInt(5, 30) }) } let rubisEarned = 0 if (spec.status === 'in_relance' || spec.status === 'awaiting_user_confirmation') { rubisEarned = randomInt(1, 3) } else if (spec.status === 'paid') { rubisEarned = randomInt(1, 4) } const invoice = await Invoice.create( { organizationId, clientId: client.id, planId: plan?.id ?? null, numero: deriveInvoiceNumero(spec.filename, issueDate.year), amountTtcCents: randomInt(25_000, 800_000), issueDate, dueDate, paidAt, status: spec.status, pdfStorageKey: storageKey, rubisEarned, notes: pickRandom(INVOICE_NOTES_POOL), }, { client: trx } ) invoices.push(invoice) await makeActivityForInvoice({ organizationId, invoice, client, trx }) if (plan) { await seedRelanceTasksForInvoice(invoice, plan, trx) } } const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) return { clients, invoices, rubisEarned } } // --------------------------------------------------------------------------- // Recette "historique" — factures paid réparties sur N mois (alimente les // graphes : encaissé mensuel, DSO, etc.). Pas de PDF, juste des données pour // que les charts soient parlants. // --------------------------------------------------------------------------- type HistoricalSeedConfig = { organizationId: string clients: Client[] plans: Plan[] trx: TransactionClientContract /** Total cents que la somme des paid doit approcher. */ targetRevenueCents: number /** Nombre de factures à générer. */ invoiceCount: number /** Profondeur en mois (issueDate étalée sur cette fenêtre). */ monthsBack: number } async function seedHistoricalInvoices( config: HistoricalSeedConfig ): Promise { const { organizationId, clients, plans, trx, monthsBack } = config if (clients.length === 0) return { clients, invoices: [], rubisEarned: 0 } // ~5% de cancelled pour la variété (factures annulées en cours de route). const cancelledIndexes = new Set() const cancelledCount = Math.round(config.invoiceCount * 0.05) while (cancelledIndexes.size < cancelledCount) { cancelledIndexes.add(randomInt(0, config.invoiceCount - 1)) } // Distribution des montants — log-uniforme entre 0.3 et 2.8, puis rescale // pour matcher le CA cible. Ça donne une queue avec quelques grosses // factures et beaucoup de petites — réaliste pour une TPE. const paidCount = config.invoiceCount - cancelledIndexes.size const rawAmounts = Array.from( { length: paidCount }, () => 0.3 + Math.random() * 2.5 ) const sumRaw = rawAmounts.reduce((a, b) => a + b, 0) const scale = config.targetRevenueCents / sumRaw const paidAmounts = rawAmounts.map((r) => Math.max(20_000, Math.round(r * scale))) const invoices: Invoice[] = [] const today = DateTime.utc().startOf('day') let paidIdx = 0 for (let i = 0; i < config.invoiceCount; i++) { const isCancelled = cancelledIndexes.has(i) const client = clients[i % clients.length]! // Plans optionnels — la moitié des historiques n'en avait pas (saisie // manuelle), pour varier. const plan = i % 2 === 0 ? (plans[i % plans.length] ?? null) : null // Date d'émission étalée sur monthsBack mois (avec jitter par jour). const monthOffset = randomInt(0, monthsBack - 1) const dayJitter = randomInt(0, 27) const issueDate = today .minus({ months: monthOffset }) .startOf('month') .plus({ days: dayJitter }) const paymentTerm = 30 const dueDate = issueDate.plus({ days: paymentTerm }) let amountTtcCents: number let paidAt: DateTime | null = null let status: InvoiceStatus let rubisEarned = 0 if (isCancelled) { // Cancelled : montant petit-moyen, pas de paidAt, pas de rubis. amountTtcCents = randomInt(30_000, 200_000) status = 'cancelled' } else { amountTtcCents = paidAmounts[paidIdx++]! // Paiement entre -3j (avance) et +25j (retard) par rapport à dueDate. paidAt = dueDate.plus({ days: randomInt(-3, 25) }) // Mais jamais après aujourd'hui. if (paidAt > today) paidAt = today.minus({ days: randomInt(0, 5) }) status = 'paid' // ~70% des paid ont déclenché 0-2 relances avant règlement. rubisEarned = Math.random() < 0.7 ? randomInt(0, 2) : 0 } const numero = `F${issueDate.year}-H${String(i + 1).padStart(4, '0')}` const invoice = await Invoice.create( { organizationId, clientId: client.id, planId: plan?.id ?? null, numero, amountTtcCents, issueDate, dueDate, paidAt, status, pdfStorageKey: null, rubisEarned, notes: pickRandom(INVOICE_NOTES_POOL), }, { client: trx } ) invoices.push(invoice) // Activity events minimaux pour la timeline (import + paiement). await ActivityEvent.create( { organizationId, kind: 'invoice_imported', at: issueDate, label: `Facture ${numero} importée`, meta: { invoiceId: invoice.id, clientId: client.id }, }, { client: trx } ) if (status === 'paid' && paidAt) { await ActivityEvent.create( { organizationId, kind: 'invoice_paid', at: paidAt, label: `${client.name} a réglé ${numero}`, meta: { invoiceId: invoice.id, clientId: client.id }, }, { client: trx } ) } } const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) return { clients, invoices, rubisEarned } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min } function pickRandom(arr: readonly T[]): T { return arr[Math.floor(Math.random() * arr.length)]! } // Marque l'export de randomUUID utilisée si on en a besoin ailleurs. export { randomUUID }