import { DateTime } from 'luxon' import RelanceTask from '#models/relance_task' import Plan from '#models/plan' import Invoice from '#models/invoice' import { getQueue } from '#services/queue' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' const RELANCE_QUEUE = 'relances' /** * 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 sendAt est dans le passé (cas : facture importée avec une dueDate * ancienne), on programme quand même la task pour `now + 1 min` — l'user * est probablement en train de "rattraper" un retard, l'envoi immédiat * est cohérent. * * 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 = getQueue(RELANCE_QUEUE) for (const t of existing) { if (t.queueJobId) { 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[] = [] for (const step of plan.steps) { const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays }) const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : 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 = await queue.add( 'send-relance', { taskId: task.id }, { delay, // Idempotency : un seul job actif par task. 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 }, } ) 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 = getQueue(RELANCE_QUEUE) for (const t of tasks) { if (t.queueJobId) { await queue.remove(t.queueJobId).catch(() => {}) } t.useTransaction(trx ?? null as never) t.status = 'cancelled' await t.save() } }