feat: email de remerciement automatique après confirmation de paiement
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m43s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m16s

Quand l'utilisateur confirme « Oui, payé » via check-in (lien email ou modale
in-app) ou marque une facture encaissée manuellement, on envoie automatiquement
un email de remerciement chaleureux au client final. Subject + body éditables
par plan (mêmes variables que les relances), avec fallback hardcodé si vide.
Gardé par la transition `* → paid` pour idempotence ; envoi async via BullMQ
avec retry exponentiel ; capture en mode démo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-09 16:41:26 +02:00
parent 2b34388723
commit 77fdb6af48
25 changed files with 755 additions and 16 deletions

View File

@ -4,6 +4,7 @@ import InvoiceTransformer from '#transformers/invoice_transformer'
import { hashCheckinToken } from '#services/checkin_token'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
import * as clock from '#services/clock'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
@ -91,6 +92,11 @@ export default class CheckinController {
}
const { task, invoice } = result
// Capture la transition `* → paid` AVANT le commit pour décider d'enqueuer
// le remerciement (idempotence : si la facture était déjà payée, on
// n'envoie pas un 2e thanks).
const wasUnpaid = invoice.status !== 'paid'
await db.transaction(async (trx) => {
const nowOrg = await clock.now(invoice.organizationId)
task.useTransaction(trx)
@ -100,7 +106,7 @@ export default class CheckinController {
await task.save()
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
if (invoice.status !== 'paid') {
if (wasUnpaid) {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = nowOrg
@ -124,6 +130,12 @@ export default class CheckinController {
}
})
// Enqueue après le commit — l'envoi est asynchrone, sans bloquer le
// redirect SPA. Skippé silencieusement si Redis est down.
if (wasUnpaid) {
await enqueuePaymentThanks(invoice.id)
}
return response.redirect(spaRedirectUrl('paid', invoice))
}
@ -198,6 +210,8 @@ export default class CheckinController {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
const wasUnpaid = invoice.status !== 'paid'
await db.transaction(async (trx) => {
const nowOrg = await clock.now(invoice.organizationId)
@ -215,7 +229,7 @@ export default class CheckinController {
await task.save()
}
if (invoice.status !== 'paid') {
if (wasUnpaid) {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = nowOrg
@ -239,6 +253,10 @@ export default class CheckinController {
}
})
if (wasUnpaid) {
await enqueuePaymentThanks(invoice.id)
}
return response.json({ data: serializeInvoice(invoice) })
}

View File

@ -11,6 +11,7 @@ import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
import { canCreateInvoices } from '#services/billing'
import logger from '@adonisjs/core/services/logger'
import * as clock from '#services/clock'
@ -434,6 +435,12 @@ export default class InvoicesController {
await cancelCheckinForInvoice(invoice.id, trx)
})
// Enqueue le mail de remerciement après commit. Cohérent avec le flow
// check-in : mark-paid manuel = même intention utilisateur ("j'ai été payé").
// L'early-return en haut de la méthode (idempotence si déjà payée) garantit
// qu'on n'arrive ici que sur transition réelle * → paid.
await enqueuePaymentThanks(invoice.id)
return response.json({ data: serializeInvoice(invoice) })
}
}

View File

@ -156,6 +156,8 @@ export default class PlansController {
plan.useTransaction(trx)
if (payload.name !== undefined) plan.name = payload.name
if (payload.description !== undefined) plan.description = payload.description
if (payload.thanksSubject !== undefined) plan.thanksSubject = payload.thanksSubject
if (payload.thanksBody !== undefined) plan.thanksBody = payload.thanksBody
await plan.save()
if (payload.steps !== undefined) {
@ -202,6 +204,8 @@ export default class PlansController {
name: payload.name,
description: payload.description ?? '',
isDefault: false,
thanksSubject: payload.thanksSubject ?? null,
thanksBody: payload.thanksBody ?? null,
},
{ client: trx }
)

View File

@ -0,0 +1,64 @@
import Invoice from '#models/invoice'
import User from '#models/user'
import Organization from '#models/organization'
import { sendPaymentThanksEmail } from '#services/mail_dispatcher'
import { recordActivity } from '#services/activity_recorder'
import logger from '@adonisjs/core/services/logger'
/**
* Worker BullMQ pour la queue `payment-thanks`. Idempotent au sens
* le caller (controllers) n'enqueue que sur transition `* → paid` ; le job
* lui-même se contente de loader la facture et d'envoyer.
*
* Cas no-op silencieux (pas d'erreur le job réussit) :
* - facture introuvable / supprimée
* - client sans email (déjà loggué dans sendPaymentThanksEmail)
* - utilisateur sans org (anomalie data)
*/
export async function sendPaymentThanksJob(jobData: { invoiceId: string }) {
logger.info({ invoiceId: jobData.invoiceId }, 'sendPaymentThanksJob: pick-up')
const invoice = await Invoice.query()
.where('id', jobData.invoiceId)
.preload('client')
.preload('plan')
.preload('organization')
.first()
if (!invoice) {
logger.warn({ invoiceId: jobData.invoiceId }, 'sendPaymentThanksJob: invoice not found, skip')
return
}
if (!invoice.client?.email) {
logger.warn(
{ invoiceId: invoice.id, numero: invoice.numero },
'sendPaymentThanksJob: client sans email, skip'
)
return
}
const user = await User.query().where('organization_id', invoice.organizationId).first()
// organization preloaded ci-dessus ; fallback explicite si la relation est null
// (anomalie data) — on continue avec null, le sender utilisera son fallback brand.
const organization =
invoice.organization ?? (await Organization.find(invoice.organizationId)) ?? null
await sendPaymentThanksEmail({
invoice,
client: invoice.client,
plan: invoice.plan ?? null,
user: user ?? null,
organization,
})
await recordActivity({
organizationId: invoice.organizationId,
kind: 'thanks_email_sent',
label: `Remerciement envoyé à <b>${invoice.client.name}</b> pour ${invoice.numero}`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
})
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero },
'sendPaymentThanksJob: terminé OK'
)
}

View File

@ -0,0 +1,136 @@
/**
* Template email de remerciement envoyé AU CLIENT FINAL après que
* l'utilisateur a confirmé le paiement (via check-in « Oui, payé » ou
* mark-paid manuel).
*
* Mise en page volontairement plus douce que la relance :
* - bandeau header rubis-deep avec un check pour signaler "tout est OK"
* - body interpolé (le user a écrit le mot, on garde sa voix)
* - card récap discrète : facture, montant pas de date d'échéance ni
* de retard, on est passé à autre chose.
*/
import * as React from 'react'
import { Section, Text } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type PaymentThanksEmailProps = {
/** Nom commercial visible côté client (l'org du user). */
brandName: string
invoice: {
numero: string
amountFormatted: string
}
/** Texte de remerciement (déjà interpolé) — le mot du user. */
bodyText: string
/** URL landing publique (footer cliquable « Rubis sur l'ongle »). */
landingUrl?: string
}
export function PaymentThanksEmail({
brandName,
invoice,
bodyText,
landingUrl,
}: PaymentThanksEmailProps) {
return (
<EmailLayout
preview={`Paiement reçu — facture ${invoice.numero} (${invoice.amountFormatted})`}
brandName={brandName}
brandSubtitle={`Paiement reçu · ${invoice.numero}`}
landingUrl={landingUrl}
>
{/* Petit check visuel pour signaler positivement "c'est bon". */}
<Section style={checkBadgeWrapStyle}>
<span style={checkBadgeStyle} aria-hidden="true">
</span>
</Section>
<Text style={bodyTextStyle}>{bodyText}</Text>
<Section style={summaryCardStyle}>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Facture</span>
<span style={summaryValueStyle}>{invoice.numero}</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Montant TTC</span>
<span
style={{
...summaryValueStyle,
fontSize: '18px',
fontWeight: 800,
fontVariantNumeric: 'tabular-nums',
}}
>
{invoice.amountFormatted}
</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Statut</span>
<span style={{ ...summaryValueStyle, color: BRAND.rubis }}>Réglée</span>
</Text>
</Section>
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const checkBadgeWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `0 0 ${sp.lg} 0`,
}
const checkBadgeStyle: React.CSSProperties = {
display: 'inline-block',
width: '44px',
height: '44px',
lineHeight: '44px',
textAlign: 'center',
borderRadius: '999px',
backgroundColor: BRAND.rubisGlow,
color: BRAND.rubisDeep,
fontSize: '22px',
fontWeight: 800,
}
const bodyTextStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line',
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: BRAND.white,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: BRAND.ink3,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: BRAND.ink,
fontWeight: 600,
}

View File

@ -3,7 +3,12 @@ import ActivityEvent from '#models/activity_event'
import * as clock from '#services/clock'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
type EventKind =
| 'relance_sent'
| 'invoice_paid'
| 'invoice_imported'
| 'warning_drafted'
| 'thanks_email_sent'
type RecordOpts = {
organizationId: string

View File

@ -26,6 +26,13 @@ type DefaultPlan = {
name: string
description: string
steps: DefaultStep[]
/**
* Email de remerciement envoyé au client final dès que l'utilisateur
* confirme avoir reçu le paiement (via check-in ou mark-paid). Mêmes
* variables que les steps (cf. mail_dispatcher buildRelanceVars).
*/
thanksSubject: string
thanksBody: string
}
export const DEFAULT_PLANS: DefaultPlan[] = [
@ -34,6 +41,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
name: 'Standard B2B',
description:
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
thanksSubject: 'Merci ! Bien reçu pour la facture {{numero}}',
thanksBody:
"Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement et au plaisir de continuer à travailler ensemble.\n\n{{signature}}",
steps: [
{
order: 0,
@ -68,6 +78,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
slug: 'rapide-15j',
name: 'Rapide',
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
thanksSubject: 'Paiement bien reçu — facture {{numero}}',
thanksBody:
'Bonjour {{client.name}},\n\nMerci, nous avons bien reçu le règlement de la facture {{numero}} ({{amount}}). Bonne continuation.\n\n{{signature}}',
steps: [
{
order: 0,
@ -99,6 +112,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
slug: 'patient-60j',
name: 'Patient',
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
thanksSubject: 'Merci — règlement bien reçu pour {{numero}}',
thanksBody:
"Bonjour {{client.name}},\n\nNous accusons bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci de votre confiance, à très bientôt.\n\n{{signature}}",
steps: [
{
order: 0,
@ -122,6 +138,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
slug: 'ferme-7j',
name: 'Ferme',
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
thanksSubject: 'Règlement reçu — facture {{numero}}',
thanksBody:
'Bonjour {{client.name}},\n\nNous confirmons la bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci.\n\n{{signature}}',
steps: [
{
order: 0,
@ -181,6 +200,8 @@ export async function provisionDefaultPlans(
name: tpl.name,
description: tpl.description,
isDefault: true,
thanksSubject: tpl.thanksSubject,
thanksBody: tpl.thanksBody,
},
{ client: trx }
)

View File

@ -17,7 +17,7 @@ import Organization from '#models/organization'
*/
export type CaptureInput = {
organizationId: string
kind: 'relance' | 'checkin'
kind: 'relance' | 'checkin' | 'thanks'
to: { email: string; name?: string | null }
from: { email: string; name?: string | null }
replyTo?: string | null

View File

@ -8,12 +8,23 @@ import * as clock from '#services/clock'
import { captureEmailIfDemo } from '#services/demo/capture'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type Plan from '#models/plan'
import type PlanStep from '#models/plan_step'
import type User from '#models/user'
import type Organization from '#models/organization'
import { CheckinEmail } from '#mails/checkin_email'
import { RelanceEmail } from '#mails/relance_email'
import { PaymentThanksEmail } from '#mails/payment_thanks_email'
/**
* Templates par défaut utilisés quand le plan d'une org n'a pas (ou plus)
* de `thanksSubject` / `thanksBody` posé. On préfère un envoi un peu
* générique à un envoi raté l'activation est systématique en V1.
*/
export const FALLBACK_THANKS_SUBJECT = 'Paiement bien reçu — facture {{numero}}'
export const FALLBACK_THANKS_BODY =
"Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement.\n\n{{signature}}"
type RelancePayload = {
invoice: Invoice
@ -287,3 +298,130 @@ L'équipe Rubis`
.text(body)
})
}
type PaymentThanksPayload = {
invoice: Invoice
client: Client
/** Plan associé à la facture. null = plan supprimé / non rattaché → fallback. */
plan: Plan | null
user: User | null
organization?: Organization | null
}
/**
* Envoie un email de remerciement AU CLIENT FINAL après que l'utilisateur
* a confirmé le paiement (check-in « Oui, payé » ou mark-paid manuel).
*
* Subject + body proviennent du plan (`thanksSubject` / `thanksBody`),
* avec un fallback hardcodé si l'utilisateur a un plan custom sans
* template défini. Mêmes variables que les relances (cf. `buildRelanceVars`).
*
* Skip silencieusement si :
* - le client n'a pas d'email (no-op + log warn)
* - l'org est en démo (capture dans `demo_captured_emails`)
*/
export async function sendPaymentThanksEmail({
invoice,
client,
plan,
user,
organization,
}: PaymentThanksPayload): Promise<void> {
if (!client.email) {
logger.warn(
{ invoiceId: invoice.id, numero: invoice.numero, clientId: client.id },
'sendPaymentThanksEmail: client sans email, skip'
)
return
}
const vars = buildRelanceVars({
invoice,
client,
user,
organization,
now: await clock.now(invoice.organizationId),
})
const subjectTpl = plan?.thanksSubject ?? FALLBACK_THANKS_SUBJECT
const bodyTpl = plan?.thanksBody ?? FALLBACK_THANKS_BODY
const subject = renderTemplate(subjectTpl, vars)
const body = renderTemplate(bodyTpl, vars)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
// Le client connaît l'org du user (ex: « Arthur Barré »), pas Rubis.
// Aligné avec sendRelanceEmail.
const fromName =
organization?.name?.trim() ||
user?.fullName?.trim() ||
env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
PaymentThanksEmail({
brandName: fromName,
invoice: {
numero: invoice.numero,
amountFormatted: formatAmountFr(invoice.amountTtcCents),
},
bodyText: body,
landingUrl,
})
)
// FORK DÉMO — capture si demoMode (cf. sendRelanceEmail).
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'thanks',
to: { email: client.email, name: client.name },
from: { email: fromAddress, name: fromName },
replyTo: user?.email ?? null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, planId: plan?.id ?? null },
})
if (captured) {
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, to: client.email },
"sendPaymentThanksEmail: capturé en mode démo (pas d'envoi réel)"
)
return
}
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
to: client.email,
from: fromAddress,
driver,
subjectPreview: subject.slice(0, 80),
},
'sendPaymentThanksEmail: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(client.email, client.name)
.subject(subject)
.html(htmlBody)
.text(body)
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, driver },
'sendPaymentThanksEmail: send OK'
)
} catch (err) {
logger.error(
{ err, invoiceId: invoice.id, numero: invoice.numero, driver },
'sendPaymentThanksEmail: échec envoi'
)
throw err
}
}

View File

@ -0,0 +1,56 @@
import { getQueue } from '#services/queue'
import app from '@adonisjs/core/services/app'
import logger from '@adonisjs/core/services/logger'
const PAYMENT_THANKS_QUEUE = 'payment-thanks'
/**
* En tests, on skip l'enqueue BullMQ les transactions auto-rollback
* laisseraient des jobs orphelins en Redis sinon, et on ne dépend pas
* d'une instance Redis live pour rouler les tests. Aligné avec le pattern
* de `relance_scheduler.shouldEnqueue()`.
*/
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Enqueue l'envoi de l'email de remerciement pour une facture donnée.
*
* À appeler **après commit** de la transaction qui transitionne la facture
* vers `paid` (check-in confirm, mark-paid manuel). Les controllers gardent
* la responsabilité de l'idempotence : on n'enqueue que sur transition réelle
* `* → paid`, pas si la facture était déjà payée.
*
* Le job tourne avec un retry exponentiel (5 tentatives, backoff 30s) : si
* Resend / Mailpit est down, on retente. Échec définitif log via le hook
* `worker.on('failed')` côté queue.ts.
*/
export async function enqueuePaymentThanks(invoiceId: string): Promise<void> {
if (!shouldEnqueue()) return
try {
const queue = getQueue(PAYMENT_THANKS_QUEUE)
await queue.add(
'send-thanks',
{ invoiceId },
{
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret. Idempotency
// via jobId : 2 enqueue concurrents pour la même invoice → un seul job.
jobId: `thanks-${invoiceId}`,
attempts: 5,
backoff: { type: 'exponential', delay: 30_000 },
// L'email est non-critique : on ne le rejoue pas indéfiniment, et on
// garde une trace des derniers jobs failés pour debug.
removeOnComplete: { age: 24 * 3600, count: 1000 },
removeOnFail: { age: 7 * 24 * 3600 },
}
)
logger.info({ invoiceId }, 'enqueuePaymentThanks: job enqueué')
} catch (err) {
// Ne pas faire échouer la requête HTTP du user pour un problème Redis.
// Le mark-paid a réussi en DB, c'est l'essentiel ; le remerciement est un
// « nice to have » qu'on pourra rattraper manuellement si besoin.
logger.error({ err, invoiceId }, 'enqueuePaymentThanks: échec enqueue (no-op)')
}
}

View File

@ -27,6 +27,8 @@ export default class PlanTransformer extends BaseTransformer<Plan> {
description: p.description,
isDefault: p.isDefault,
steps: steps.map(serializeStep),
thanksSubject: p.thanksSubject,
thanksBody: p.thanksBody,
createdAt: p.createdAt.toISO()!,
updatedAt: p.updatedAt?.toISO() ?? p.createdAt.toISO()!,
}

View File

@ -18,11 +18,16 @@ const planStep = vine.object({
/**
* Validator pour PATCH /plans/:slug. Tous les champs optionnels l'éditeur
* front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie.
*
* `thanksSubject` / `thanksBody` : nullable pour permettre à l'utilisateur
* d'effacer le template (retomber sur le fallback hardcodé).
*/
export const updatePlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80).optional(),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10).optional(),
thanksSubject: vine.string().maxLength(200).nullable().optional(),
thanksBody: vine.string().maxLength(5000).nullable().optional(),
})
/**
@ -33,4 +38,6 @@ export const createPlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10),
thanksSubject: vine.string().maxLength(200).nullable().optional(),
thanksBody: vine.string().maxLength(5000).nullable().optional(),
})

View File

@ -18,7 +18,7 @@ export const redisConnection: RedisOptions = {
* Liste des queues. La concurrence est appliquée côté worker.
* Ajouter une queue ici ajouter un Worker correspondant dans #start/queue.ts.
*/
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis'] as const
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis', 'payment-thanks'] as const
export type QueueName = (typeof queueNames)[number]
export const queueConcurrency: Record<QueueName, number> = {
@ -26,4 +26,5 @@ export const queueConcurrency: Record<QueueName, number> = {
relances: 5,
checkins: 5,
kpis: 1,
'payment-thanks': 5,
}

View File

@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'plans'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Email de remerciement envoyé au client final dès que l'utilisateur
// confirme avoir été payé (via check-in ou mark-paid). Nullable parce
// que les plans custom existants n'auront rien tant que l'user n'a pas
// édité — le mail_dispatcher applique un fallback hardcodé.
table.text('thanks_subject').nullable()
table.text('thanks_body').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('thanks_subject')
table.dropColumn('thanks_body')
})
}
}

View File

@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Étend l'enum natif Postgres `activity_event_kind` avec la valeur
* `thanks_email_sent` (timeline de la facture lorsque l'email de
* remerciement automatique part au client après confirmation de paiement).
*/
export default class extends BaseSchema {
// Postgres interdit ALTER TYPE ... ADD VALUE à l'intérieur d'une transaction.
// On désactive le wrap transactionnel pour cette migration spécifiquement.
static disableTransactions = true
async up() {
this.schema.raw("ALTER TYPE activity_event_kind ADD VALUE IF NOT EXISTS 'thanks_email_sent'")
}
async down() {
// Postgres ne supporte pas le retrait d'une valeur d'enum sans recréer
// le type. On laisse la valeur en place côté DB — le code ne l'utilisera
// simplement plus si on rollback. Suffisant pour cette migration.
}
}

View File

@ -0,0 +1,61 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Backfill des templates de remerciement sur les plans pré-fournis qui
* existaient AVANT l'ajout des colonnes `thanks_subject` / `thanks_body`.
*
* `provisionDefaultPlans` est idempotent sur slug il skip si le plan
* existe déjà donc les orgs créées avant la migration `1778260000000_*`
* gardaient ces colonnes à NULL et l'éditeur de plan affichait du vide
* (le mailer tombait sur le fallback hardcodé, donc fonctionnellement OK,
* mais pas la promesse « éditable par plan »).
*
* On UPDATE seulement les rows avec thanks_subject IS NULL pour ne pas
* écraser les éventuelles éditions utilisateur. Les valeurs miroirent
* exactement DEFAULT_PLANS dans `app/services/default_plans.ts`.
*/
const DEFAULTS: Array<{ slug: string; subject: string; body: string }> = [
{
slug: 'standard-30j',
subject: 'Merci ! Bien reçu pour la facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement et au plaisir de continuer à travailler ensemble.\n\n{{signature}}",
},
{
slug: 'rapide-15j',
subject: 'Paiement bien reçu — facture {{numero}}',
body:
'Bonjour {{client.name}},\n\nMerci, nous avons bien reçu le règlement de la facture {{numero}} ({{amount}}). Bonne continuation.\n\n{{signature}}',
},
{
slug: 'patient-60j',
subject: 'Merci — règlement bien reçu pour {{numero}}',
body:
"Bonjour {{client.name}},\n\nNous accusons bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci de votre confiance, à très bientôt.\n\n{{signature}}",
},
{
slug: 'ferme-7j',
subject: 'Règlement reçu — facture {{numero}}',
body:
'Bonjour {{client.name}},\n\nNous confirmons la bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci.\n\n{{signature}}',
},
]
export default class extends BaseSchema {
async up() {
for (const tpl of DEFAULTS) {
await this.db
.from('plans')
.where('slug', tpl.slug)
.whereNull('thanks_subject')
.update({ thanks_subject: tpl.subject, thanks_body: tpl.body })
}
}
async down() {
// No-op : on ne sait pas distinguer nos backfills d'éventuelles éditions
// utilisateur identiques. Les colonnes elles-mêmes sont droppées par la
// migration 1778260000000_add_thanks_template_to_plans_table down().
}
}

View File

@ -17,7 +17,7 @@ export class ActivityEventSchema extends BaseModel {
@column({ isPrimary: true })
declare id: string
@column()
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' | 'thanks_email_sent'
@column()
declare label: string
@column()
@ -286,7 +286,7 @@ export class PlanStepSchema extends BaseModel {
}
export class PlanSchema extends BaseModel {
static $columns = ['createdAt', 'description', 'id', 'isDefault', 'name', 'organizationId', 'slug', 'updatedAt'] as const
static $columns = ['createdAt', 'description', 'id', 'isDefault', 'name', 'organizationId', 'slug', 'thanksBody', 'thanksSubject', 'updatedAt'] as const
$columns = PlanSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@ -302,6 +302,10 @@ export class PlanSchema extends BaseModel {
declare organizationId: string
@column()
declare slug: string | null
@column()
declare thanksBody: string | null
@column()
declare thanksSubject: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
@ -404,7 +408,7 @@ export class RelanceTaskSchema extends BaseModel {
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'isAdmin', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const
$columns = UserSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@ -417,6 +421,8 @@ export class UserSchema extends BaseModel {
@column({ isPrimary: true })
declare id: string
@column()
declare isAdmin: boolean
@column()
declare microsoftId: string | null
@column()
declare organizationId: string | null

View File

@ -19,6 +19,7 @@ import logger from '@adonisjs/core/services/logger'
import { registerWorker, shutdownQueue } from '#services/queue'
import { sendRelanceJob } from '#jobs/send_relance_job'
import { sendCheckinJob } from '#jobs/send_checkin_job'
import { sendPaymentThanksJob } from '#jobs/send_payment_thanks_job'
if (app.getEnvironment() === 'web') {
try {
@ -30,7 +31,11 @@ if (app.getEnvironment() === 'web') {
await sendCheckinJob(job.data)
})
logger.info('BullMQ workers ready (relances, checkins)')
registerWorker<{ invoiceId: string }>('payment-thanks', async (job) => {
await sendPaymentThanksJob(job.data)
})
logger.info('BullMQ workers ready (relances, checkins, payment-thanks)')
app.terminating(async () => {
logger.info('shutting down BullMQ workers')

View File

@ -1,6 +1,13 @@
import { format, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import { Send, CheckCircle2, Inbox, AlertTriangle, type LucideIcon } from "lucide-react";
import {
Send,
CheckCircle2,
Inbox,
AlertTriangle,
MailCheck,
type LucideIcon,
} from "lucide-react";
import { Card } from "@rubis/ui";
import { Eyebrow } from "@rubis/ui";
@ -15,7 +22,8 @@ export type ActivityKind =
| "relance_sent"
| "invoice_paid"
| "invoice_imported"
| "warning_drafted";
| "warning_drafted"
| "thanks_email_sent";
export type ActivityEvent = {
id: string;
@ -31,6 +39,7 @@ const ICONS: Record<ActivityKind, LucideIcon> = {
invoice_paid: CheckCircle2,
invoice_imported: Inbox,
warning_drafted: AlertTriangle,
thanks_email_sent: MailCheck,
};
const TONE: Record<ActivityKind, string> = {
@ -38,6 +47,7 @@ const TONE: Record<ActivityKind, string> = {
invoice_paid: "text-rubis-deep",
invoice_imported: "text-ink-2",
warning_drafted: "text-rubis-deep",
thanks_email_sent: "text-rubis",
};
type ActivityFeedProps = {

View File

@ -306,7 +306,9 @@ export const mockDb = {
updatePlan(
orgId: string,
id: string,
patch: Partial<Pick<Plan, "name" | "description" | "steps">>,
patch: Partial<
Pick<Plan, "name" | "description" | "steps" | "thanksSubject" | "thanksBody">
>,
): Plan | undefined {
const db = load();
const idx = db.plans.findIndex(

View File

@ -41,12 +41,16 @@ const updatePlanSchema = z.object({
name: z.string().min(1).max(80).optional(),
description: z.string().max(500).optional(),
steps: z.array(updatePlanStepSchema).min(1).max(10).optional(),
thanksSubject: z.string().max(200).nullable().optional(),
thanksBody: z.string().max(5000).nullable().optional(),
});
const createPlanSchema = z.object({
name: z.string().min(1).max(80),
description: z.string().max(500).optional(),
steps: z.array(updatePlanStepSchema).min(1).max(10),
thanksSubject: z.string().max(200).nullable().optional(),
thanksBody: z.string().max(5000).nullable().optional(),
});
const RESERVED_SLUGS = new Set(["nouveau", "new", "create"]);
@ -141,6 +145,8 @@ export const planHandlers = [
...s,
id: s.id ?? `step_${planId}_${idx}_${Date.now()}`,
})),
thanksSubject: parsed.data.thanksSubject ?? null,
thanksBody: parsed.data.thanksBody ?? null,
});
return HttpResponse.json({ data: created }, { status: 201 });
}),
@ -181,6 +187,12 @@ export const planHandlers = [
description: parsed.data.description,
}),
...(steps !== undefined && { steps }),
...(parsed.data.thanksSubject !== undefined && {
thanksSubject: parsed.data.thanksSubject,
}),
...(parsed.data.thanksBody !== undefined && {
thanksBody: parsed.data.thanksBody,
}),
});
return HttpResponse.json({ data: updated });

View File

@ -99,6 +99,9 @@ export const SEED_PLANS: Plan[] = [
name: "Standard B2B",
description: "Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.",
isDefault: true,
thanksSubject: "Merci ! Bien reçu pour la facture {{numero}}",
thanksBody:
"Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement et au plaisir de continuer à travailler ensemble.\n\n{{signature}}",
steps: [
{
id: "step_std_1",
@ -138,6 +141,9 @@ export const SEED_PLANS: Plan[] = [
name: "Rapide",
description: "Cadence resserrée pour les factures récurrentes ou les délais courts.",
isDefault: true,
thanksSubject: "Paiement bien reçu — facture {{numero}}",
thanksBody:
"Bonjour {{client.name}},\n\nMerci, nous avons bien reçu le règlement de la facture {{numero}} ({{amount}}). Bonne continuation.\n\n{{signature}}",
steps: [
{
id: "step_rap_1",
@ -177,6 +183,9 @@ export const SEED_PLANS: Plan[] = [
name: "Patient",
description: "Pour les clients de longue date. On laisse respirer avant de relancer.",
isDefault: true,
thanksSubject: "Merci — règlement bien reçu pour {{numero}}",
thanksBody:
"Bonjour {{client.name}},\n\nNous accusons bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci de votre confiance, à très bientôt.\n\n{{signature}}",
steps: [
{
id: "step_pat_1",
@ -207,6 +216,9 @@ export const SEED_PLANS: Plan[] = [
name: "Ferme",
description: "Cadence stricte pour les clients à risque ou les retards récurrents.",
isDefault: true,
thanksSubject: "Règlement reçu — facture {{numero}}",
thanksBody:
"Bonjour {{client.name}},\n\nNous confirmons la bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci.\n\n{{signature}}",
steps: [
{
id: "step_fer_1",

View File

@ -57,17 +57,31 @@ function PlanEditorPage() {
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
// Email de remerciement (envoyé au client après confirmation de paiement).
// null = utiliser le fallback côté API. On hydrate depuis le serveur.
const [draftThanksSubject, setDraftThanksSubject] = useState<string | null>(null);
const [draftThanksBody, setDraftThanksBody] = useState<string | null>(null);
const thanksBodyRef = useRef<HTMLTextAreaElement | null>(null);
// Sync : quand le plan arrive ou change côté serveur, on remet à zéro l'état
// local. On évite les races avec une clé sur plan.id+updatedAt.
useEffect(() => {
if (!plan) return;
setDraftSteps(plan.steps);
setSelectedStepId((current) => current ?? plan.steps[0]?.id ?? null);
setDraftThanksSubject(plan.thanksSubject);
setDraftThanksBody(plan.thanksBody);
}, [plan?.id, plan?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
type SavePayload = {
steps: PlanStep[];
thanksSubject: string | null;
thanksBody: string | null;
};
const saveMutation = useMutation({
mutationFn: (steps: PlanStep[]) =>
api.patch<Plan>(`/api/v1/plans/${slug}`, { steps }),
mutationFn: (payload: SavePayload) =>
api.patch<Plan>(`/api/v1/plans/${slug}`, payload),
onSuccess: (saved) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
void queryClient.setQueryData(
@ -130,8 +144,25 @@ function PlanEditorPage() {
});
};
const insertVariableInThanks = (token: string) => {
const ta = thanksBodyRef.current;
if (!ta) return;
const current = draftThanksBody ?? "";
const start = ta.selectionStart ?? current.length;
const end = ta.selectionEnd ?? current.length;
const newBody = current.slice(0, start) + token + current.slice(end);
setDraftThanksBody(newBody);
requestAnimationFrame(() => {
ta.focus();
const cursor = start + token.length;
ta.setSelectionRange(cursor, cursor);
});
};
const isDirty =
JSON.stringify(draftSteps) !== JSON.stringify(plan.steps);
JSON.stringify(draftSteps) !== JSON.stringify(plan.steps) ||
draftThanksSubject !== plan.thanksSubject ||
draftThanksBody !== plan.thanksBody;
const mood = planMoodLabel({ steps: draftSteps });
return (
@ -173,7 +204,13 @@ function PlanEditorPage() {
size="sm"
disabled={!isDirty}
loading={saveMutation.isPending}
onClick={() => saveMutation.mutate(draftSteps)}
onClick={() =>
saveMutation.mutate({
steps: draftSteps,
thanksSubject: draftThanksSubject,
thanksBody: draftThanksBody,
})
}
>
{isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
@ -301,6 +338,84 @@ function PlanEditorPage() {
)}
</section>
</div>
{/* === Email de remerciement (envoyé après confirmation de paiement) === */}
<section
aria-label="Email de remerciement"
className="flex flex-col gap-3"
>
<Eyebrow tone="ink">Email de remerciement</Eyebrow>
<Card padding="md" className="flex flex-col gap-5">
<p className="text-[13px] text-ink-2">
Envoyé automatiquement à votre client dès que vous confirmez avoir
reçu le paiement (réponse « Oui, payé » au check-in ou bouton
« Marquer encaissée »).
</p>
<Field
label="Objet de l'email"
htmlFor={`thanks-subject-${plan.id}`}
>
<Input
id={`thanks-subject-${plan.id}`}
value={draftThanksSubject ?? ""}
onChange={(e) =>
setDraftThanksSubject(
e.target.value === "" ? null : e.target.value,
)
}
placeholder="Merci ! Bien reçu pour la facture {{numero}}"
/>
</Field>
<Field
label="Corps"
htmlFor={`thanks-body-${plan.id}`}
hint="Mêmes variables que les étapes de relance. Laisser vide pour utiliser le template par défaut."
>
<Textarea
id={`thanks-body-${plan.id}`}
ref={thanksBodyRef}
rows={8}
className="font-mono text-[13px] leading-relaxed"
value={draftThanksBody ?? ""}
onChange={(e) =>
setDraftThanksBody(
e.target.value === "" ? null : e.target.value,
)
}
/>
</Field>
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Variables clic pour insérer au curseur
</p>
<div className="flex flex-wrap gap-2">
{TEMPLATE_VARIABLES.map((variable) => (
<button
key={`thanks-${variable.token}`}
type="button"
onClick={() => insertVariableInThanks(variable.token)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1",
"font-mono text-[11.5px] leading-tight",
"border-line bg-cream-2 text-ink-2",
"transition-colors hover:border-rubis hover:bg-rubis-glow hover:text-rubis-deep",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
title={`Insère "${variable.token}" — ex: ${variable.preview.split("\n")[0]}`}
>
<span className="font-sans text-[11px] text-ink-3 not-italic">
{variable.label}
</span>
<span className="text-rubis">{variable.token}</span>
</button>
))}
</div>
</div>
</Card>
</section>
</div>
);
}

View File

@ -23,6 +23,10 @@ export const createPlanSchema = z.object({
.array(planStepSchema)
.min(1, "Au moins une étape")
.max(10, "Pas plus de 10 étapes — on reste raisonnable"),
// Email de remerciement envoyé au client après confirmation de paiement.
// null/absent = utiliser le fallback hardcodé côté API.
thanksSubject: z.string().max(200).nullable().optional(),
thanksBody: z.string().max(5000).nullable().optional(),
});
export const updatePlanSchema = createPlanSchema.partial();

View File

@ -30,6 +30,14 @@ export type Plan = {
description: string;
isDefault: boolean;
steps: PlanStep[];
/**
* Sujet du mail de remerciement envoyé au client final dès que l'utilisateur
* confirme avoir reçu le paiement. null fallback sur un template par défaut
* hardcodé côté API. Mêmes variables que les steps (cf. mail_dispatcher).
*/
thanksSubject: string | null;
/** Corps du mail de remerciement (markdown léger). null ⇒ fallback API. */
thanksBody: string | null;
createdAt: string;
updatedAt: string;
};