rubis/apps/api/database/factories.ts
ordinarthur b96b62aab6
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 57s
Build & Deploy API / build-and-deploy (push) Successful in 1m40s
feat(seed): génération PDF cohérente par facture via @react-pdf/renderer
Chaque facture seedée a maintenant son propre PDF dont le contenu
matche exactement les meta DB (vendeur = org du user, client = client
réel, numéro / dates / montant cohérents). Plus de réutilisation
round-robin de PDFs disque non-cohérents.

Stack :
  - @react-pdf/renderer : composants React déclaratifs, StyleSheet
    inspiré du SPA (mêmes tokens couleur Rubis), même mental model que
    le frontend.
  - InvoiceDocument décomposé en sous-composants Header / Addresses /
    ItemsTable / Totals / Footer pour itération facile.
  - Items générés depuis un pool B2B (conseil, dev, audit, formation,
    livraison, photo, …) avec quantités/prix unitaires qui s'ajustent
    pour que la somme matche le total TTC stocké.

Le command `seed:demo --reset` :
  - wipe les invoice-pdfs/{orgId}/* sur MinIO (paginé)
  - re-génère 227 PDFs (27 actionnables + 200 historiques)
  - CA cumulé paid ≈ 400 K€ pile sur la cible

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:59:55 +02:00

860 lines
29 KiB
TypeScript
Raw Permalink 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.

/**
* 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<PdfInvoiceInput['seller']> {
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<string> {
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<number> {
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<Client> {
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<void> {
const { invoice, client, trx } = input
await ActivityEvent.create(
{
organizationId: input.organizationId,
kind: 'invoice_imported',
at: invoice.issueDate,
label: `Facture <b>${invoice.numero}</b> 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 à <b>${client.name}</b>`,
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: `<b>${client.name}</b> 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<DemoSeedResult> {
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<Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> }>
trx: TransactionClientContract
seller: PdfInvoiceInput['seller']
}
async function seedActionableInvoices(
config: ActionableSeedConfig
): Promise<DemoSeedResult> {
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<Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> }>
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<DemoSeedResult> {
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<number>()
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 <b>${numero}</b> 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: `<b>${client.name}</b> 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<void> {
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<T>(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<T>(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 }