rubis/apps/api/database/factories.ts
ordinarthur 1633fb9bf0
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 59s
Build & Deploy API / build-and-deploy (push) Successful in 1m37s
add factories
2026-05-07 11:34:00 +02:00

809 lines
26 KiB
TypeScript

/**
* 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<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']
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<Invoice> {
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<void> {
const { invoice, client, trx } = input
// Import — toujours
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 — 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<DemoSeedResult> {
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<DemoSeedResult> {
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 racine du repo. Le command
* tourne depuis `apps/api/`, donc on remonte de 2 niveaux.
*/
function resolveTestInvoicesDir(): string | null {
const candidates = [
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<void> {
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<DemoSeedResult> {
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<DemoSeedResult> {
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<number>()
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 <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 }
}
// ---------------------------------------------------------------------------
// 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)]!
}
// Marque l'export de randomUUID utilisée si on en a besoin ailleurs.
export { randomUUID }