diff --git a/apps/api/adonisrc.ts b/apps/api/adonisrc.ts
index 624913f..bd413b5 100644
--- a/apps/api/adonisrc.ts
+++ b/apps/api/adonisrc.ts
@@ -73,6 +73,7 @@ export default defineConfig({
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
+ () => import('#start/queue'),
],
/*
diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts
index d78ca08..7be813d 100644
--- a/apps/api/app/controllers/import_batches_controller.ts
+++ b/apps/api/app/controllers/import_batches_controller.ts
@@ -16,6 +16,7 @@ import {
type ImportSource,
} from '#services/import_batch'
import { recordActivity } from '#services/activity_recorder'
+import { scheduleRelancesForInvoice } from '#services/relance_scheduler'
import drive from '@adonisjs/drive/services/main'
import { createReadStream } from 'node:fs'
import { randomUUID } from 'node:crypto'
@@ -221,6 +222,10 @@ export default class ImportBatchesController {
await invoice.load('client')
await invoice.load('plan')
+ if (invoice.planId) {
+ await scheduleRelancesForInvoice(invoice)
+ }
+
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
}
diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts
index 8e58481..8267c0b 100644
--- a/apps/api/app/controllers/invoices_controller.ts
+++ b/apps/api/app/controllers/invoices_controller.ts
@@ -11,6 +11,10 @@ import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
+import {
+ scheduleRelancesForInvoice,
+ cancelFutureRelances,
+} from '#services/relance_scheduler'
const PAGE_SIZE = 50
@@ -296,13 +300,20 @@ export default class InvoicesController {
await invoice.load('client')
await invoice.load('plan')
+ // Programme les relances BullMQ si la facture a un plan. Hors tx :
+ // les jobs sont posés dans Redis, on n'a pas besoin de cohérence DB
+ // (et BullMQ.add() retourne avant d'écrire à Redis sur certains modes).
+ if (invoice.planId) {
+ await scheduleRelancesForInvoice(invoice)
+ }
+
return response.status(201).json({ data: serializeInvoice(invoice) })
}
/**
* POST /invoices/:id/mark-paid
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
- * et sur organization.rubisCount).
+ * et sur organization.rubisCount). Annule toutes les relances futures.
*/
async markPaid({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
@@ -343,6 +354,11 @@ export default class InvoicesController {
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
+
+ // Annule toutes les relances futures programmées pour cette facture
+ // (idempotent, BullMQ.remove peut échouer silencieusement si le
+ // job a déjà été consommé).
+ await cancelFutureRelances(invoice.id, trx)
})
return response.json({ data: serializeInvoice(invoice) })
diff --git a/apps/api/app/jobs/send_relance_job.ts b/apps/api/app/jobs/send_relance_job.ts
new file mode 100644
index 0000000..af4d861
--- /dev/null
+++ b/apps/api/app/jobs/send_relance_job.ts
@@ -0,0 +1,115 @@
+import RelanceTask from '#models/relance_task'
+import Invoice from '#models/invoice'
+import User from '#models/user'
+import { sendRelanceEmail } from '#services/mail_dispatcher'
+import { recordActivity } from '#services/activity_recorder'
+import db from '@adonisjs/lucid/services/db'
+import { DateTime } from 'luxon'
+import logger from '@adonisjs/core/services/logger'
+
+/**
+ * Worker BullMQ pour la queue `relances`. Idempotent : si la task n'est
+ * plus `scheduled` (déjà envoyée, annulée, ou échouée définitivement),
+ * no-op.
+ *
+ * Cas critiques :
+ * - Invoice payée/annulée entre temps → cancel la task (pas d'envoi)
+ * - Step `requires_manual_validation` (mise en demeure) → on n'envoie
+ * PAS, on log un activity_event 'warning_drafted' que l'utilisateur
+ * devra valider manuellement (cf. CLAUDE.md → Principes produit).
+ * - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
+ */
+export async function sendRelanceJob(jobData: { taskId: string }) {
+ const task = await RelanceTask.query()
+ .where('id', jobData.taskId)
+ .preload('planStep')
+ .first()
+ if (!task) {
+ logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
+ return
+ }
+ if (task.status !== 'scheduled') {
+ logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
+ return
+ }
+
+ const invoice = await Invoice.query()
+ .where('id', task.invoiceId)
+ .preload('client')
+ .first()
+ if (!invoice) {
+ task.status = 'cancelled'
+ await task.save()
+ return
+ }
+
+ // Hook critique : la facture peut avoir été payée entre la programmation
+ // et l'exécution. On vérifie avant d'envoyer.
+ if (invoice.status === 'paid' || invoice.status === 'cancelled') {
+ task.status = 'cancelled'
+ await task.save()
+ return
+ }
+
+ const step = task.planStep
+ const user = await User.query().where('organization_id', invoice.organizationId).first()
+
+ // Mise en demeure : on génère un brouillon, on n'envoie pas (cf. CLAUDE.md).
+ if (step.requiresManualValidation) {
+ await db.transaction(async (trx) => {
+ task.useTransaction(trx)
+ task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output
+ task.sentAt = DateTime.now()
+ await task.save()
+
+ await recordActivity({
+ organizationId: invoice.organizationId,
+ kind: 'warning_drafted',
+ label: `Brouillon mise en demeure prêt — ${invoice.client.name} (${invoice.numero})`,
+ meta: {
+ invoiceId: invoice.id,
+ clientId: invoice.clientId,
+ planStepOrder: step.order,
+ },
+ trx,
+ })
+ })
+ return
+ }
+
+ // Envoi normal
+ await sendRelanceEmail({ invoice, client: invoice.client, step, user })
+
+ await db.transaction(async (trx) => {
+ task.useTransaction(trx)
+ task.status = 'sent'
+ task.sentAt = DateTime.now()
+ await task.save()
+
+ invoice.useTransaction(trx)
+ // Première relance envoyée → status passe en `in_relance` (la facture
+ // sort de l'état "pending" silencieux).
+ if (invoice.status === 'pending') {
+ invoice.status = 'in_relance'
+ }
+ invoice.rubisEarned = invoice.rubisEarned + 1
+ await invoice.save()
+
+ await trx
+ .from('organizations')
+ .where('id', invoice.organizationId)
+ .increment('rubis_count', 1)
+
+ await recordActivity({
+ organizationId: invoice.organizationId,
+ kind: 'relance_sent',
+ label: `Relance J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} envoyée à ${invoice.client.name}`,
+ meta: {
+ invoiceId: invoice.id,
+ clientId: invoice.clientId,
+ planStepOrder: step.order,
+ },
+ trx,
+ })
+ })
+}
diff --git a/apps/api/app/models/checkin_task.ts b/apps/api/app/models/checkin_task.ts
new file mode 100644
index 0000000..d3d1433
--- /dev/null
+++ b/apps/api/app/models/checkin_task.ts
@@ -0,0 +1,9 @@
+import { CheckinTaskSchema } from '#database/schema'
+import { belongsTo } from '@adonisjs/lucid/orm'
+import type { BelongsTo } from '@adonisjs/lucid/types/relations'
+import Invoice from '#models/invoice'
+
+export default class CheckinTask extends CheckinTaskSchema {
+ @belongsTo(() => Invoice)
+ declare invoice: BelongsTo
+}
diff --git a/apps/api/app/models/relance_task.ts b/apps/api/app/models/relance_task.ts
new file mode 100644
index 0000000..bbf5ce9
--- /dev/null
+++ b/apps/api/app/models/relance_task.ts
@@ -0,0 +1,13 @@
+import { RelanceTaskSchema } from '#database/schema'
+import { belongsTo } from '@adonisjs/lucid/orm'
+import type { BelongsTo } from '@adonisjs/lucid/types/relations'
+import Invoice from '#models/invoice'
+import PlanStep from '#models/plan_step'
+
+export default class RelanceTask extends RelanceTaskSchema {
+ @belongsTo(() => Invoice)
+ declare invoice: BelongsTo
+
+ @belongsTo(() => PlanStep, { foreignKey: 'planStepId' })
+ declare planStep: BelongsTo
+}
diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts
new file mode 100644
index 0000000..1ec4ab3
--- /dev/null
+++ b/apps/api/app/services/mail_dispatcher.ts
@@ -0,0 +1,47 @@
+import mail from '@adonisjs/mail/services/main'
+import env from '#start/env'
+import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
+import type Invoice from '#models/invoice'
+import type Client from '#models/client'
+import type PlanStep from '#models/plan_step'
+import type User from '#models/user'
+
+type RelancePayload = {
+ invoice: Invoice
+ client: Client
+ step: PlanStep
+ user: User | null
+}
+
+/**
+ * Envoie un email de relance à un client à partir d'un step.
+ * Le subject/body du step contiennent des placeholders Mustache-like
+ * (`{{client.name}}`, `{{numero}}`, `{{amount}}`, `{{dueDate}}`,
+ * `{{signature}}`) qu'on interpole avant l'envoi.
+ *
+ * Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
+ * `resend` en prod).
+ */
+export async function sendRelanceEmail({ invoice, client, step, user }: RelancePayload) {
+ const vars = {
+ client: { name: client.name, email: client.email },
+ numero: invoice.numero,
+ amount: formatAmountFr(invoice.amountTtcCents),
+ dueDate: formatDateFr(invoice.dueDate.toJSDate()),
+ issueDate: formatDateFr(invoice.issueDate.toJSDate()),
+ signature: user?.signature ?? user?.fullName ?? '',
+ }
+
+ const subject = renderTemplate(step.subject, vars)
+ const body = renderTemplate(step.body, vars)
+
+ const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
+ await mailer.send((m) => {
+ m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
+ .to(client.email, client.name)
+ .subject(subject)
+ // Texte brut pour V1 — on ajoutera un template HTML quand on aura
+ // décidé d'un look graphique pour les relances.
+ .text(body)
+ })
+}
diff --git a/apps/api/app/services/queue.ts b/apps/api/app/services/queue.ts
new file mode 100644
index 0000000..07baabe
--- /dev/null
+++ b/apps/api/app/services/queue.ts
@@ -0,0 +1,60 @@
+import { Queue, Worker, type Processor } from 'bullmq'
+import { redisConnection, queueNames, type QueueName } from '#config/queue'
+import logger from '@adonisjs/core/services/logger'
+
+/**
+ * Wrappers BullMQ partagés. Chaque queue a 1 instance Queue (producer)
+ * et N workers (consumers) avec le bon handler.
+ *
+ * V1 : on garde tout en mémoire process — workers et HTTP partagent le
+ * même Node. Quand le volume justifie le coût, on extrait les workers
+ * dans un Deployment K3s séparé (cf. backend.md §13.4).
+ */
+
+const queues = new Map()
+const workers: Worker[] = []
+
+export function getQueue(name: QueueName): Queue {
+ let q = queues.get(name)
+ if (!q) {
+ q = new Queue(name, { connection: redisConnection })
+ queues.set(name, q)
+ }
+ return q
+}
+
+export type JobHandler = Processor
+
+/**
+ * Enregistre un Worker BullMQ sur une queue. Démarre tout de suite.
+ * Appelé par start/queue.ts au boot pour câbler les handlers.
+ */
+export function registerWorker(name: QueueName, handler: JobHandler): Worker {
+ const worker = new Worker(name, handler, {
+ connection: redisConnection,
+ concurrency: 5,
+ })
+ worker.on('failed', (job, err) => {
+ logger.error({ err, queue: name, jobId: job?.id }, 'job failed')
+ })
+ worker.on('completed', (job) => {
+ logger.info({ queue: name, jobId: job.id }, 'job completed')
+ })
+ workers.push(worker)
+ return worker
+}
+
+/**
+ * Stoppe proprement tous les workers + queues. Appelé au shutdown du
+ * process via Adonis terminating hook.
+ */
+export async function shutdownQueue(): Promise {
+ await Promise.all(workers.map((w) => w.close()))
+ await Promise.all(Array.from(queues.values()).map((q) => q.close()))
+}
+
+/**
+ * Liste des noms de queue (re-export du config pour ne pas exposer la
+ * connection Redis ailleurs dans l'app).
+ */
+export const QUEUES = queueNames
diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts
new file mode 100644
index 0000000..4579719
--- /dev/null
+++ b/apps/api/app/services/relance_scheduler.ts
@@ -0,0 +1,121 @@
+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()
+ }
+}
diff --git a/apps/api/app/services/template.ts b/apps/api/app/services/template.ts
new file mode 100644
index 0000000..3f42a4d
--- /dev/null
+++ b/apps/api/app/services/template.ts
@@ -0,0 +1,37 @@
+/**
+ * Mini interpolateur Mustache-like utilisé pour les sujets/corps des
+ * emails de relance. Supporte les chemins pointés (`{{client.name}}`).
+ *
+ * Volontairement simple : pas d'expressions, pas de conditions, pas de
+ * boucles. Si un chemin manque, retourne "" (silencieux — l'utilisateur
+ * verra un blanc, pas une exception).
+ */
+export function renderTemplate(template: string, vars: Record): string {
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
+ const parts = path.split('.')
+ let val: unknown = vars
+ for (const p of parts) {
+ if (val == null || typeof val !== 'object') return ''
+ val = (val as Record)[p]
+ }
+ return val == null ? '' : String(val)
+ })
+}
+
+/**
+ * Helper d'affichage montant : 12400 → "124,00 €".
+ */
+export function formatAmountFr(cents: number): string {
+ return new Intl.NumberFormat('fr-FR', {
+ style: 'currency',
+ currency: 'EUR',
+ }).format(cents / 100)
+}
+
+/**
+ * Helper d'affichage date : ISO/Date → "15/04/2026".
+ */
+export function formatDateFr(d: Date | string): string {
+ const date = typeof d === 'string' ? new Date(d) : d
+ return new Intl.DateTimeFormat('fr-FR').format(date)
+}
diff --git a/apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts b/apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts
new file mode 100644
index 0000000..a8eaa78
--- /dev/null
+++ b/apps/api/database/migrations/1778080001000_create_relance_tasks_table.ts
@@ -0,0 +1,60 @@
+import { BaseSchema } from '@adonisjs/lucid/schema'
+
+export default class extends BaseSchema {
+ protected tableName = 'relance_tasks'
+
+ async up() {
+ this.schema.createTable(this.tableName, (table) => {
+ table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
+ // Pas de FK org : passe par invoice → org. Mais on garde un cache
+ // pour les requêtes scope-by-org sans join (dashboard, jobs).
+ table
+ .uuid('organization_id')
+ .notNullable()
+ .references('id')
+ .inTable('organizations')
+ .onDelete('CASCADE')
+ table
+ .uuid('invoice_id')
+ .notNullable()
+ .references('id')
+ .inTable('invoices')
+ .onDelete('CASCADE')
+ table
+ .uuid('plan_step_id')
+ .notNullable()
+ .references('id')
+ .inTable('plan_steps')
+ // RESTRICT pour éviter qu'une suppression d'étape (cas rare V1
+ // puisque l'édition de plan recrée tout) casse les tasks programmées.
+ // En pratique, l'édition de plan en cours fait un DELETE+INSERT
+ // des steps — les tasks pointant sur les vieux steps perdent
+ // leur référence et il faut les annuler. Géré côté édition plan.
+ .onDelete('RESTRICT')
+
+ table.timestamp('send_at').notNullable()
+ table
+ .enum('status', ['scheduled', 'sent', 'cancelled', 'failed'], {
+ useNative: true,
+ enumName: 'relance_task_status',
+ })
+ .notNullable()
+ .defaultTo('scheduled')
+ table.timestamp('sent_at').nullable()
+ // ID BullMQ pour pouvoir cancel le job programmé.
+ table.string('queue_job_id', 100).nullable()
+
+ table.timestamp('created_at').notNullable()
+ table.timestamp('updated_at').nullable()
+
+ table.index(['organization_id', 'status'])
+ table.index(['invoice_id'])
+ table.index(['send_at'])
+ })
+ }
+
+ async down() {
+ this.schema.dropTable(this.tableName)
+ this.schema.raw('DROP TYPE IF EXISTS relance_task_status')
+ }
+}
diff --git a/apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts b/apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts
new file mode 100644
index 0000000..94fac81
--- /dev/null
+++ b/apps/api/database/migrations/1778080001100_create_checkin_tasks_table.ts
@@ -0,0 +1,49 @@
+import { BaseSchema } from '@adonisjs/lucid/schema'
+
+export default class extends BaseSchema {
+ protected tableName = 'checkin_tasks'
+
+ async up() {
+ this.schema.createTable(this.tableName, (table) => {
+ table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
+ table
+ .uuid('organization_id')
+ .notNullable()
+ .references('id')
+ .inTable('organizations')
+ .onDelete('CASCADE')
+ table
+ .uuid('invoice_id')
+ .notNullable()
+ .references('id')
+ .inTable('invoices')
+ .onDelete('CASCADE')
+
+ table.timestamp('send_at').notNullable()
+ // Token signé HMAC, TTL 24h après émission. Stocké hashé.
+ table.string('token_hash', 64).notNullable().unique()
+ table
+ .enum('status', ['scheduled', 'sent', 'answered', 'expired'], {
+ useNative: true,
+ enumName: 'checkin_task_status',
+ })
+ .notNullable()
+ .defaultTo('scheduled')
+ table.timestamp('sent_at').nullable()
+ table.timestamp('answered_at').nullable()
+ // Réponse de l'utilisateur via le lien email : 'paid' ou 'still_pending'
+ table.string('answer', 20).nullable()
+
+ table.timestamp('created_at').notNullable()
+ table.timestamp('updated_at').nullable()
+
+ table.index(['organization_id', 'status'])
+ table.index(['invoice_id'])
+ })
+ }
+
+ async down() {
+ this.schema.dropTable(this.tableName)
+ this.schema.raw('DROP TYPE IF EXISTS checkin_task_status')
+ }
+}
diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts
index 89896c8..897a2db 100644
--- a/apps/api/database/schema.ts
+++ b/apps/api/database/schema.ts
@@ -53,6 +53,33 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null
}
+export class CheckinTaskSchema extends BaseModel {
+ static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const
+ $columns = CheckinTaskSchema.$columns
+ @column()
+ declare answer: 'paid' | 'still_pending' | null | null
+ @column.dateTime()
+ declare answeredAt: DateTime | null
+ @column.dateTime({ autoCreate: true })
+ declare createdAt: DateTime
+ @column({ isPrimary: true })
+ declare id: string
+ @column()
+ declare invoiceId: string
+ @column()
+ declare organizationId: string
+ @column.dateTime()
+ declare sendAt: DateTime
+ @column.dateTime()
+ declare sentAt: DateTime | null
+ @column()
+ declare status: 'scheduled' | 'sent' | 'answered' | 'expired'
+ @column()
+ declare tokenHash: string
+ @column.dateTime({ autoCreate: true, autoUpdate: true })
+ declare updatedAt: DateTime | null
+}
+
export class ClientSchema extends BaseModel {
static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
$columns = ClientSchema.$columns
@@ -245,6 +272,31 @@ export class RefreshTokenSchema extends BaseModel {
declare userId: string
}
+export class RelanceTaskSchema extends BaseModel {
+ static $columns = ['createdAt', 'id', 'invoiceId', 'organizationId', 'planStepId', 'queueJobId', 'sendAt', 'sentAt', 'status', 'updatedAt'] as const
+ $columns = RelanceTaskSchema.$columns
+ @column.dateTime({ autoCreate: true })
+ declare createdAt: DateTime
+ @column({ isPrimary: true })
+ declare id: string
+ @column()
+ declare invoiceId: string
+ @column()
+ declare organizationId: string
+ @column()
+ declare planStepId: string
+ @column()
+ declare queueJobId: string | null
+ @column.dateTime()
+ declare sendAt: DateTime
+ @column.dateTime()
+ declare sentAt: DateTime | null
+ @column()
+ declare status: 'scheduled' | 'sent' | 'cancelled' | 'failed'
+ @column.dateTime({ autoCreate: true, autoUpdate: true })
+ declare updatedAt: DateTime | null
+}
+
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
$columns = UserSchema.$columns
diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts
index 4355a2b..a94c0ad 100644
--- a/apps/api/database/schema_rules.ts
+++ b/apps/api/database/schema_rules.ts
@@ -33,6 +33,23 @@ export default {
},
},
},
+ relance_tasks: {
+ columns: {
+ status: {
+ tsType: "'scheduled' | 'sent' | 'cancelled' | 'failed'",
+ },
+ },
+ },
+ checkin_tasks: {
+ columns: {
+ status: {
+ tsType: "'scheduled' | 'sent' | 'answered' | 'expired'",
+ },
+ answer: {
+ tsType: "'paid' | 'still_pending' | null",
+ },
+ },
+ },
import_drafts: {
columns: {
status: {
diff --git a/apps/api/package.json b/apps/api/package.json
index 341d561..6ab89a7 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -23,6 +23,7 @@
"#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js",
"#services/*": "./app/services/*.js",
+ "#jobs/*": "./app/jobs/*.js",
"#listeners/*": "./app/listeners/*.js",
"#events/*": "./app/events/*.js",
"#generated/*": "./.adonisjs/server/*.js",
diff --git a/apps/api/start/queue.ts b/apps/api/start/queue.ts
new file mode 100644
index 0000000..6274f3d
--- /dev/null
+++ b/apps/api/start/queue.ts
@@ -0,0 +1,30 @@
+/*
+|--------------------------------------------------------------------------
+| Queue workers
+|--------------------------------------------------------------------------
+|
+| Boot des workers BullMQ. V1 : on les démarre dans le même process que
+| l'API HTTP (simple, suffisant tant que le volume reste petit). En prod
+| K3s on les extraira dans un Deployment séparé (cf. backend.md §13.4).
+|
+*/
+
+import app from '@adonisjs/core/services/app'
+import logger from '@adonisjs/core/services/logger'
+import { registerWorker, shutdownQueue } from '#services/queue'
+import { sendRelanceJob } from '#jobs/send_relance_job'
+
+// On enregistre les workers seulement quand l'app sert HTTP — pas en
+// mode test (pour ne pas connecter Redis pendant les tests Japa) ni en
+// REPL (pour ne pas déclencher d'exécution latérale).
+if (app.getEnvironment() === 'web') {
+ logger.info('booting BullMQ workers (relances)')
+ registerWorker<{ taskId: string }>('relances', async (job) => {
+ await sendRelanceJob(job.data)
+ })
+
+ app.terminating(async () => {
+ logger.info('shutting down BullMQ workers')
+ await shutdownQueue()
+ })
+}
diff --git a/bruno/05-Invoices/04 Create.bru b/bruno/05-Invoices/04 Create.bru
index 7d8fd87..7e444aa 100644
--- a/bruno/05-Invoices/04 Create.bru
+++ b/bruno/05-Invoices/04 Create.bru
@@ -50,6 +50,15 @@ docs {
`client_email_required`
Bonus +1 rubis à la création (gamification).
-
+
+ Si `planId` est fourni : programme automatiquement les RelanceTasks
+ BullMQ pour chaque step du plan (sendAt = dueDate + offsetDays).
+ Les jobs scheduled sont visibles via :
+ `docker exec rubis-redis redis-cli zrange bull:relances:delayed 0 -1`
+
+ Pour tester l'envoi immédiat : passer une `dueDate` dans le passé →
+ la première RelanceTask est programmée à `now + 1min`. Mailpit
+ http://localhost:8025 affichera le mail capté ~1min plus tard.
+
Capture `invoiceId` dans l'env pour les requêtes suivantes.
}