rubis/apps/api/app/services/relance_scheduler.ts
ordinarthur a6b35dfe7a feat(api): RelanceTask + CheckinTask + worker BullMQ qui envoie les relances
Migrations :
- relance_tasks (uuid id, organization_id FK CASCADE [scope direct sans join], invoice_id FK CASCADE, plan_step_id FK RESTRICT, send_at, status ENUM scheduled/sent/cancelled/failed, sent_at, queue_job_id pour cancel via BullMQ.remove). Indexes (org,status), (invoice_id), (send_at).
- checkin_tasks (uuid id, org_id, invoice_id, send_at, token_hash unique [SHA-256 du HMAC, TTL 24h], status ENUM scheduled/sent/answered/expired, answer 'paid'|'still_pending'). Pas encore branché — flow check-in arrivera dans un commit séparé (cf. backend.md §13.3).

Schema rules : status enums + answer typés.

Models RelanceTask + CheckinTask avec belongsTo Invoice / PlanStep.

Service relance_scheduler.ts :
- scheduleRelancesForInvoice(invoice) : pour chaque step du plan, calcule sendAt = dueDate + offsetDays. Si sendAt < now (facture importée en retard), on programme à `now + 1min` plutôt que skip — l'utilisateur "rattrape" une dette de relance, l'envoi immédiat est cohérent. Crée la RelanceTask + enqueue BullMQ avec delay, retry 5x exponential, jobId = `relance:<taskId>` pour idempotency. Cancelle les tasks scheduled existantes avant de re-programmer (gestion changement de plan).
- cancelFutureRelances(invoiceId, trx) : appelé par mark-paid pour stopper la chaîne.

Service queue.ts :
- getQueue(name) singleton lazy par queue
- registerWorker(name, handler) avec concurrency 5, log failed/completed
- shutdownQueue() pour le terminating hook Adonis

start/queue.ts (preload) : registerWorker('relances', sendRelanceJob) seulement quand `app.getEnvironment() === 'web'` (pas en tests/REPL — pas de connexion Redis pendant Japa).

Job send_relance_job.ts :
- Idempotent : si task.status !== 'scheduled', no-op
- Hook critique : si invoice paid/cancelled entre-temps, task.status = cancelled
- Mise en demeure (step.requiresManualValidation) : on n'envoie PAS, on log un activity_event 'warning_drafted' (cf. CLAUDE.md → Principes : validation manuelle obligatoire)
- Sinon : sendRelanceEmail + task.status=sent + invoice.rubisEarned+1 + organizations.rubis_count+1 + activity_event 'relance_sent'. Si invoice.status='pending', passe en 'in_relance' (sortie de l'état silencieux).

Service mail_dispatcher.ts : sendRelanceEmail interpole step.subject/body via mini moteur Mustache-like (renderTemplate, services/template.ts) avec {{client.name}}/{{numero}}/{{amount}}/{{dueDate}}/{{signature}}, puis @adonisjs/mail.use(MAIL_DRIVER) → Mailpit en dev, Resend en prod. Texte brut V1.

Triggers branchés :
- InvoicesController.store : si planId, scheduleRelancesForInvoice après création
- ImportBatchesController.validateDraft : pareil
- InvoicesController.markPaid : cancelFutureRelances dans la même tx que le paiement

#jobs/* ajouté aux imports package.json. Adonisrc preload start/queue.ts.

Bruno : doc 05-Invoices/04 Create maj avec instructions pour tester l'envoi immédiat (dueDate dans le passé → relance à now+1min → email visible dans Mailpit http://localhost:8025).
2026-05-06 15:24:46 +02:00

122 lines
3.7 KiB
TypeScript

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<RelanceTask[]> {
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<void> {
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()
}
}