import { DateTime } from 'luxon' import RelanceTask from '#models/relance_task' import Plan from '#models/plan' import type Invoice from '#models/invoice' import { getQueue } from '#services/queue' import app from '@adonisjs/core/services/app' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' const RELANCE_QUEUE = 'relances' /** * En tests, les RelanceTasks DB sont créées (utile pour assertions) mais * l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs * orphelins en Redis sinon, et on ne veut pas dépendre d'une instance * Redis live pour tourner les tests. */ function shouldEnqueue(): boolean { return app.getEnvironment() !== 'test' } /** * Programme toutes les relances d'une facture selon son plan. * * - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays * - Crée une RelanceTask `scheduled` * - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now * * Si une facture est déjà en retard quand l'utilisateur confirme "toujours * en attente", on n'envoie pas toutes les étapes passées d'un coup : * la première étape éligible part à `now + 1 min`, puis les suivantes * gardent l'écart du plan à partir de ce nouveau départ. * * Idempotent par invoice.id : si des tasks `scheduled` existent déjà * pour cette facture, on les annule avant de re-programmer (cas où on * change de plan). */ export async function scheduleRelancesForInvoice( invoice: Invoice, trx?: TransactionClientContract ): Promise { if (!invoice.planId) return [] const plan = await Plan.query(trx ? { client: trx } : undefined) .where('id', invoice.planId) .preload('steps', (q) => q.orderBy('order', 'asc')) .first() if (!plan) return [] // Cancel les tasks scheduled existantes (re-scheduling après changement // de plan ou de dueDate). const existing = await RelanceTask.query(trx ? { client: trx } : undefined) .where('invoice_id', invoice.id) .where('status', 'scheduled') const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null for (const t of existing) { if (t.queueJobId && queue) { await queue.remove(t.queueJobId).catch(() => { // Ignore — le job peut déjà être consommé. }) } t.useTransaction(trx ?? (null as never)) t.status = 'cancelled' await t.save() } const now = DateTime.now() const created: RelanceTask[] = [] const steps = plan.steps.slice().sort((a, b) => a.order - b.order) const firstOverdueStep = steps.find( (step) => invoice.dueDate.plus({ days: step.offsetDays }) < now ) const catchUpAnchor = firstOverdueStep ? { offsetDays: firstOverdueStep.offsetDays, sendAt: now.plus({ minutes: 1 }), } : null for (const step of steps) { const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays }) const sendAt = catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays ? catchUpAnchor.sendAt.plus({ days: step.offsetDays - catchUpAnchor.offsetDays, }) : sendAtRaw const task = await RelanceTask.create( { organizationId: invoice.organizationId, invoiceId: invoice.id, planStepId: step.id, sendAt, status: 'scheduled', sentAt: null, queueJobId: null, }, trx ? { client: trx } : undefined ) const delay = Math.max(0, sendAt.toMillis() - now.toMillis()) const job = queue ? await queue.add( 'send-relance', { taskId: task.id }, { delay, // Idempotency : un seul job actif par task. // BullMQ 5+ interdit `:` dans les custom jobIds → tiret. jobId: `relance-${task.id}`, // Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec // backoff (cf. backend.md §13.2). attempts: 5, backoff: { type: 'exponential', delay: 30_000 }, } ) : null task.queueJobId = job?.id ?? null await task.save() created.push(task) } return created } /** * Annule toutes les relances futures d'une facture (appelé quand on * mark-paid ou cancel une invoice). Les tasks déjà `sent` restent * intactes — c'est de l'historique. */ export async function cancelFutureRelances( invoiceId: string, trx?: TransactionClientContract ): Promise { const tasks = await RelanceTask.query(trx ? { client: trx } : undefined) .where('invoice_id', invoiceId) .where('status', 'scheduled') if (tasks.length === 0) return const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null for (const t of tasks) { if (t.queueJobId && queue) { await queue.remove(t.queueJobId).catch(() => {}) } t.useTransaction(trx ?? (null as never)) t.status = 'cancelled' await t.save() } }