/** * Factories — créent des entités réalistes (FR, format Rubis) pour * peupler une org de démo. * * Particularité notable : pour CHAQUE invoice on génère un vrai PDF * cohérent (vendeur = org du user, client = client réel, montant et * dates = données BDD) via `invoice_pdf_factory.ts`, puis on l'upload * sur MinIO. Les fiches facture montrent ainsi un document réel * dont le contenu correspond exactement aux meta de la BDD. */ import { DateTime } from 'luxon' import { randomUUID } from 'node:crypto' 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' import Organization from '#models/organization' import User from '#models/user' import { generateInvoicePdfBuffer, type InvoiceItem, type PdfInvoiceInput, } from '#database/invoice_pdf_factory' // --------------------------------------------------------------------------- // 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: '3 impasse des Artisans, 33000 Bordeaux', 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: '17 rue des Lilas, 44000 Nantes', 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, ] /** * Pool d'items B2B variés. Chaque item a une fourchette de prix unitaire * (HT, en €) — on tire random à l'intérieur, et la quantité s'ajuste * pour atteindre le total cible. */ const ITEM_POOL: Array<{ description: string unit: string /** Prix unitaire HT, fourchette en € (pas en centimes). */ unitPriceMinEur: number unitPriceMaxEur: number }> = [ { description: 'Conseil stratégique', unit: 'jour', unitPriceMinEur: 600, unitPriceMaxEur: 1200 }, { description: 'Développement sur mesure', unit: 'h', unitPriceMinEur: 80, unitPriceMaxEur: 150 }, { description: 'Audit financier', unit: 'forfait', unitPriceMinEur: 800, unitPriceMaxEur: 2400 }, { description: 'Maintenance trimestrielle', unit: 'forfait', unitPriceMinEur: 400, unitPriceMaxEur: 900 }, { description: 'Formation équipe', unit: 'jour', unitPriceMinEur: 800, unitPriceMaxEur: 1500 }, { description: 'Design graphique', unit: 'h', unitPriceMinEur: 60, unitPriceMaxEur: 120 }, { description: 'Livraison matières premières', unit: 'palette', unitPriceMinEur: 180, unitPriceMaxEur: 800 }, { description: 'Prestation photographique', unit: 'jour', unitPriceMinEur: 500, unitPriceMaxEur: 1100 }, { description: 'Travaux second œuvre', unit: 'jour', unitPriceMinEur: 380, unitPriceMaxEur: 850 }, { description: 'Pain de campagne 1kg', unit: 'u', unitPriceMinEur: 4, unitPriceMaxEur: 8 }, { description: 'Quiche lorraine 6 parts', unit: 'u', unitPriceMinEur: 12, unitPriceMaxEur: 18 }, { description: 'Composition florale événement', unit: 'u', unitPriceMinEur: 40, unitPriceMaxEur: 110 }, { description: 'Réparation moteur', unit: 'forfait', unitPriceMinEur: 220, unitPriceMaxEur: 1500 }, { description: 'Impression brochures', unit: '1000ex', unitPriceMinEur: 90, unitPriceMaxEur: 280 }, { description: 'Couvert (déjeuner d\'affaires)', unit: 'u', unitPriceMinEur: 28, unitPriceMaxEur: 65 }, ] // --------------------------------------------------------------------------- // Vendeur (info "fixe" du PDF — l'org du user en émetteur de facture) // --------------------------------------------------------------------------- /** * Construit l'info vendeur exposée dans le PDF à partir de l'org + user. * Quelques placeholders raisonnables (adresse, IBAN, téléphone) sont * fournis car ces champs n'existent pas encore au niveau Org. * * Quand on aura `address`, `iban`, `phone` dans la table organizations, * on lira directement depuis là. */ async function buildSellerInfoForOrg( organizationId: string, trx?: TransactionClientContract ): Promise { const org = await Organization.findOrFail(organizationId, trx ? { client: trx } : undefined) const user = await User.query(trx ? { client: trx } : undefined) .where('organization_id', organizationId) .first() // Simulé pour la démo. La V2 ajoutera ces champs sur Organization. return { name: org.name || 'Mon entreprise', address: '12 rue de la République, 75001 Paris', siret: org.siret || '83245678900015', tvaNumber: org.siret ? `FR${(org.siret).slice(0, 11)}` : 'FR83245678900', email: user?.email || 'contact@rubis-demo.fr', phone: '+33 1 84 60 12 34', iban: 'FR76 3000 4012 3456 7890 1234 567', } } function clientInfoForPdf(client: Client): PdfInvoiceInput['client'] { return { name: client.name, contactFirstName: client.contactFirstName, contactLastName: client.contactLastName, address: client.address, siret: client.siret, } } // --------------------------------------------------------------------------- // Générateur d'items — crée 1-3 items dont le total HT match la cible // --------------------------------------------------------------------------- /** * Génère 1 à 3 items dont la somme des `quantity * unitPriceHtCents` * approche le `targetHtCents` donné (rounding ±1 cent par item, négligeable). */ function makeItemsForTargetHt(targetHtCents: number): InvoiceItem[] { const itemCount = targetHtCents > 80_000 ? randomInt(2, 3) : randomInt(1, 2) const picked = sampleN(ITEM_POOL, itemCount) // Splits aléatoires (ratios qui somment à 1). const ratios = picked.map(() => 0.3 + Math.random()) const ratioSum = ratios.reduce((a, b) => a + b, 0) const splits = ratios.map((r) => Math.round((targetHtCents * r) / ratioSum)) return picked.map((tpl, i) => { const itemTotal = Math.max(1_00, splits[i]!) // Prix unitaire dans la fourchette du template (en centimes). const minUnit = tpl.unitPriceMinEur * 100 const maxUnit = tpl.unitPriceMaxEur * 100 const targetUnit = randomInt(minUnit, maxUnit) // Quantité ajustée pour atteindre itemTotal en restant entière >= 1. const quantity = Math.max(1, Math.round(itemTotal / targetUnit)) // Re-calibrage du prix unitaire pour que qty*price = itemTotal pile. const unitPriceHtCents = Math.max(50, Math.round(itemTotal / quantity)) return { description: tpl.description, unit: tpl.unit, quantity, unitPriceHtCents, } }) } /** Total TTC en centimes pour une liste d'items (HT + 20% TVA). */ function computeTotalTtc(items: InvoiceItem[]): number { const ht = items.reduce((s, i) => s + i.quantity * i.unitPriceHtCents, 0) const tva = Math.round(ht * 0.2) return ht + tva } // --------------------------------------------------------------------------- // Upload PDF — génère le buffer puis push sur Drive (MinIO en prod, FS dev) // --------------------------------------------------------------------------- async function generateAndUploadInvoicePdf( organizationId: string, pdfInput: PdfInvoiceInput ): Promise { const buffer = await generateInvoicePdfBuffer(pdfInput) const storageKey = `invoice-pdfs/${organizationId}/${randomUUID()}.pdf` await drive.use().put(storageKey, buffer) return storageKey } // --------------------------------------------------------------------------- // Cleanup MinIO — drop tous les PDFs d'une org (appelé au --reset) // --------------------------------------------------------------------------- /** * Supprime tous les `invoice-pdfs/{orgId}/...` du drive. Utilisé par le * command `seed:demo --reset` pour repartir d'une MinIO propre. * * Boucle sur `paginationToken` pour gérer les listes > page-size par défaut * (généralement 1000 — suffisant en une page pour nos 227 PDFs, mais on * gère propre au cas où on monte en volume). */ export async function wipeOrgInvoicePdfs(organizationId: string): Promise { const disk = drive.use() const prefix = `invoice-pdfs/${organizationId}/` let count = 0 let paginationToken: string | undefined do { const result = await disk.listAll(prefix, { recursive: true, paginationToken, }) for (const item of result.objects) { if (!item.isFile) continue await disk.delete(item.key) count++ } paginationToken = result.paginationToken } while (paginationToken) return count } // --------------------------------------------------------------------------- // Clients // --------------------------------------------------------------------------- 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'] // --------------------------------------------------------------------------- // Activity events // --------------------------------------------------------------------------- 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 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 — orchestration globale du seed démo // --------------------------------------------------------------------------- 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 } /** * Recipe actionnable — 27 factures représentatives du quotidien : * * - 6 pending (échéance future) * - 3 awaiting_user_confirmation (échéance toute fraîche, check-in) * - 11 in_relance (déjà échues, relances en cours) * - 5 paid récentes (donnent du DSO et du delta d'encaissement) * - 2 litigation (cas tendu, mise en demeure imminente) */ type ActionableSpec = { status: InvoiceStatus /** Décalage en jours de la dueDate par rapport à aujourd'hui. */ dueOffsetDays: number } const ACTIONABLE_RECIPE: ActionableSpec[] = [ // Pending — échéance future { status: 'pending', dueOffsetDays: 30 }, { status: 'pending', dueOffsetDays: 30 }, { status: 'pending', dueOffsetDays: 15 }, { status: 'pending', dueOffsetDays: 15 }, { status: 'pending', dueOffsetDays: 5 }, { status: 'pending', dueOffsetDays: 5 }, // Awaiting check-in — échue tout pile / tout récente { status: 'awaiting_user_confirmation', dueOffsetDays: 0 }, { status: 'awaiting_user_confirmation', dueOffsetDays: -3 }, { status: 'awaiting_user_confirmation', dueOffsetDays: -3 }, // In relance — échue, relances en cours { status: 'in_relance', dueOffsetDays: -7 }, { status: 'in_relance', dueOffsetDays: -7 }, { status: 'in_relance', dueOffsetDays: -15 }, { status: 'in_relance', dueOffsetDays: -15 }, { status: 'in_relance', dueOffsetDays: -15 }, { status: 'in_relance', dueOffsetDays: -30 }, { status: 'in_relance', dueOffsetDays: -30 }, { status: 'in_relance', dueOffsetDays: -45 }, { status: 'in_relance', dueOffsetDays: -60 }, { status: 'in_relance', dueOffsetDays: -60 }, { status: 'in_relance', dueOffsetDays: -90 }, // Paid récentes (réglées tardivement, dans les ~30 derniers jours) { status: 'paid', dueOffsetDays: -30 }, { status: 'paid', dueOffsetDays: -45 }, { status: 'paid', dueOffsetDays: -60 }, { status: 'paid', dueOffsetDays: -90 }, { status: 'paid', dueOffsetDays: -120 }, // Litigation { status: 'litigation', dueOffsetDays: -120 }, { status: 'litigation', dueOffsetDays: -180 }, ] /** 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 // Charge les plans avec leurs steps (utile pour seeder les RelanceTask). const plansWithSteps = await Plan.query({ client: trx }) .whereIn( 'id', plans.map((p) => p.id) ) .preload('steps', (q) => q.orderBy('order', 'asc')) // Crée les clients du pool. 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 })) } // Vendeur — l'org du user. Construit une fois, réutilisé pour les 227 PDFs. const seller = await buildSellerInfoForOrg(organizationId, trx) // Phase 1 — Actionnables (27 factures avec PDF coherent) const actionable = await seedActionableInvoices({ organizationId, clients, plans: plansWithSteps, trx, seller, }) // Phase 2 — Historique (200 factures paid sur 12 mois pour les graphes) 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, plans: plansWithSteps, trx, seller, targetRevenueCents: historicalTarget, invoiceCount: HISTORICAL_INVOICE_COUNT, monthsBack: 12, numeroOffset: actionable.invoices.length, }) return { clients, invoices: [...actionable.invoices, ...historical.invoices], rubisEarned: actionable.rubisEarned + historical.rubisEarned, } } // --------------------------------------------------------------------------- // Phase 1 — actionnables (recipe 27 factures) // --------------------------------------------------------------------------- type ActionableSeedConfig = { organizationId: string clients: Client[] plans: Array }> trx: TransactionClientContract seller: PdfInvoiceInput['seller'] } async function seedActionableInvoices( config: ActionableSeedConfig ): Promise { const { organizationId, clients, plans, trx, seller } = config const invoices: Invoice[] = [] const today = DateTime.utc().startOf('day') const yearPrefix = today.year for (const [i, spec] of ACTIONABLE_RECIPE.entries()) { const client = clients[i % clients.length]! const plan = plans[i % plans.length] ?? null const numero = `F${yearPrefix}-${String(i + 1).padStart(4, '0')}` // Items + total TTC réel = somme exacte des items × 1.20. // Cible HT random entre 200€ et 6000€ (large fourchette, lisible). const targetHtCents = randomInt(20_000, 600_000) const items = makeItemsForTargetHt(targetHtCents) const amountTtcCents = computeTotalTtc(items) // Dates issues du recipe (terme 30 jours). const dueDate = today.plus({ days: spec.dueOffsetDays }) const issueDate = dueDate.minus({ days: 30 }) let paidAt: DateTime | null = null if (spec.status === 'paid') { // Réglée 5-30j après dueDate (mais pas dans le futur) const proposed = dueDate.plus({ days: randomInt(5, 30) }) paidAt = proposed > today ? today.minus({ days: randomInt(1, 5) }) : proposed } 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) } // Génère le PDF + upload sur MinIO const pdfStorageKey = await generateAndUploadInvoicePdf(organizationId, { seller, client: clientInfoForPdf(client), invoice: { numero, issueDate: issueDate.toJSDate(), dueDate: dueDate.toJSDate(), items, }, }) const invoice = await Invoice.create( { organizationId, clientId: client.id, planId: plan?.id ?? null, numero, amountTtcCents, issueDate, dueDate, paidAt, status: spec.status, pdfStorageKey, 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 } } // --------------------------------------------------------------------------- // Phase 2 — historique (200 paid sur 12 mois pour alimenter les graphes) // --------------------------------------------------------------------------- type HistoricalSeedConfig = { organizationId: string clients: Client[] plans: Array }> trx: TransactionClientContract seller: PdfInvoiceInput['seller'] /** Cible CA cumulé visé (centimes). Soft : approximé via les items. */ targetRevenueCents: number /** Nombre de factures à générer. */ invoiceCount: number /** Profondeur en mois (issueDate étalée sur cette fenêtre). */ monthsBack: number /** Décalage de numérotation pour ne pas écraser les actionnables (F2026-XXXX). */ numeroOffset: number } async function seedHistoricalInvoices( config: HistoricalSeedConfig ): Promise { const { organizationId, clients, plans, trx, seller, monthsBack } = config if (clients.length === 0) return { clients, invoices: [], rubisEarned: 0 } // ~5% de cancelled pour la variété. const cancelledIndexes = new Set() const cancelledCount = Math.round(config.invoiceCount * 0.05) while (cancelledIndexes.size < cancelledCount) { cancelledIndexes.add(randomInt(0, config.invoiceCount - 1)) } // Distribution de "tailles cibles" log-uniformes, rescalées pour // approximer le CA total cible. 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 targetAmounts = 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]! 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 dueDate = issueDate.plus({ days: 30 }) // Items en partant d'une cible TTC ; on dérive ensuite la cible HT. const targetTtcCents = isCancelled ? randomInt(30_000, 200_000) : targetAmounts[paidIdx++]! const targetHtCents = Math.round(targetTtcCents / 1.2) const items = makeItemsForTargetHt(targetHtCents) const amountTtcCents = computeTotalTtc(items) let paidAt: DateTime | null = null let status: InvoiceStatus let rubisEarned = 0 if (isCancelled) { status = 'cancelled' } else { status = 'paid' const proposed = dueDate.plus({ days: randomInt(-3, 25) }) paidAt = proposed > today ? today.minus({ days: randomInt(0, 5) }) : proposed rubisEarned = Math.random() < 0.7 ? randomInt(0, 2) : 0 } const numero = `F${issueDate.year}-${String(config.numeroOffset + i + 1).padStart(4, '0')}` const pdfStorageKey = await generateAndUploadInvoicePdf(organizationId, { seller, client: clientInfoForPdf(client), invoice: { numero, issueDate: issueDate.toJSDate(), dueDate: dueDate.toJSDate(), items, }, }) const invoice = await Invoice.create( { organizationId, clientId: client.id, planId: plan?.id ?? null, numero, amountTtcCents, issueDate, dueDate, paidAt, status, pdfStorageKey, rubisEarned, notes: pickRandom(INVOICE_NOTES_POOL), }, { client: trx } ) invoices.push(invoice) 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 } } // --------------------------------------------------------------------------- // RelanceTasks — câblage des tâches de relance pour une invoice (no-enqueue) // --------------------------------------------------------------------------- /** * 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 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 } ) } } // --------------------------------------------------------------------------- // 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)]! } /** Fisher-Yates partiel — sample N éléments distincts d'un array. */ function sampleN(arr: readonly T[], n: number): T[] { const copy = arr.slice() const out: T[] = [] const k = Math.min(n, copy.length) for (let i = 0; i < k; i++) { const j = randomInt(i, copy.length - 1) ;[copy[i], copy[j]] = [copy[j]!, copy[i]!] out.push(copy[i]!) } return out } export { randomUUID }