import CheckinTask from '#models/checkin_task' import Invoice from '#models/invoice' import { getQueue } from '#services/queue' import { generateCheckinToken } from '#services/checkin_token' import * as clock from '#services/clock' import app from '@adonisjs/core/services/app' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' const CHECKIN_QUEUE = 'checkins' function shouldEnqueue(): boolean { return app.getEnvironment() !== 'test' } /** * Programme un check-in pour une facture. * * V1 : 1 check-in par facture, envoyé à `dueDate` (pile à l'échéance). * Si dueDate est dans le passé → envoie immédiat (à `now + 1min`), * pour que les factures importées en retard reçoivent quand même un * check-in. * * Le token est généré ici (plain) — on retourne le plain pour permettre * au caller de le passer dans des emails de test si besoin, mais en * pratique seul le hash est stocké et lu via SendCheckinJob. * * Idempotent par invoice : si une CheckinTask `scheduled` existe déjà, * on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling * après changement de dueDate). * * En tests : la task DB est créée mais l'enqueue BullMQ est skippé * (les tx auto-rollback laisseraient des jobs orphelins en Redis sinon). */ export async function scheduleCheckinForInvoice( invoice: Invoice, trx?: TransactionClientContract ): Promise<{ task: CheckinTask; plain: string } | null> { // Cancel l'éventuelle CheckinTask scheduled précédente. const existing = await CheckinTask.query(trx ? { client: trx } : undefined) .where('invoice_id', invoice.id) .where('status', 'scheduled') const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null for (const t of existing) { if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {}) t.useTransaction(trx ?? (null as never)) t.status = 'expired' await t.save() } const now = await clock.now(invoice.organizationId) const sendAtRaw = invoice.dueDate const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw const { plain, hashed } = generateCheckinToken() const task = await CheckinTask.create( { organizationId: invoice.organizationId, invoiceId: invoice.id, sendAt, tokenHash: hashed, status: 'scheduled', sentAt: null, answeredAt: null, answer: null, }, trx ? { client: trx } : undefined ) if (queue) { const delay = Math.max(0, sendAt.toMillis() - now.toMillis()) await queue.add( 'send-checkin', { taskId: task.id, plain }, { delay, jobId: `checkin-${task.id}`, attempts: 3, backoff: { type: 'exponential', delay: 30_000 }, } ) } return { task, plain } } /** * Annule le check-in scheduled d'une facture (appelé par mark-paid). */ export async function cancelCheckinForInvoice( invoiceId: string, trx?: TransactionClientContract ): Promise { const tasks = await CheckinTask.query(trx ? { client: trx } : undefined) .where('invoice_id', invoiceId) .where('status', 'scheduled') if (tasks.length === 0) return const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null for (const t of tasks) { if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {}) t.useTransaction(trx ?? (null as never)) t.status = 'expired' await t.save() } }