diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts
index a115d0f..287fa09 100644
--- a/apps/api/app/controllers/checkin_controller.ts
+++ b/apps/api/app/controllers/checkin_controller.ts
@@ -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) })
}
diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts
index 655e3d8..4481f1a 100644
--- a/apps/api/app/controllers/invoices_controller.ts
+++ b/apps/api/app/controllers/invoices_controller.ts
@@ -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) })
}
}
diff --git a/apps/api/app/controllers/plans_controller.ts b/apps/api/app/controllers/plans_controller.ts
index 1dc582b..d3c009f 100644
--- a/apps/api/app/controllers/plans_controller.ts
+++ b/apps/api/app/controllers/plans_controller.ts
@@ -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 }
)
diff --git a/apps/api/app/jobs/send_payment_thanks_job.ts b/apps/api/app/jobs/send_payment_thanks_job.ts
new file mode 100644
index 0000000..94b9abf
--- /dev/null
+++ b/apps/api/app/jobs/send_payment_thanks_job.ts
@@ -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 où
+ * 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é à ${invoice.client.name} pour ${invoice.numero}`,
+ meta: { invoiceId: invoice.id, clientId: invoice.clientId },
+ })
+
+ logger.info(
+ { invoiceId: invoice.id, numero: invoice.numero },
+ 'sendPaymentThanksJob: terminé OK'
+ )
+}
diff --git a/apps/api/app/mails/payment_thanks_email.tsx b/apps/api/app/mails/payment_thanks_email.tsx
new file mode 100644
index 0000000..916d2e6
--- /dev/null
+++ b/apps/api/app/mails/payment_thanks_email.tsx
@@ -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 (
+
+ {/* Petit check visuel pour signaler positivement "c'est bon". */}
+
+
+ ✓
+
+
+
+ {bodyText}
+
+
+
+ Facture
+ {invoice.numero}
+
+
+ Montant TTC
+
+ {invoice.amountFormatted}
+
+
+
+ Statut
+ Réglée
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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,
+}
diff --git a/apps/api/app/services/activity_recorder.ts b/apps/api/app/services/activity_recorder.ts
index fc631b8..277a6c5 100644
--- a/apps/api/app/services/activity_recorder.ts
+++ b/apps/api/app/services/activity_recorder.ts
@@ -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
diff --git a/apps/api/app/services/default_plans.ts b/apps/api/app/services/default_plans.ts
index 6708457..e1bd066 100644
--- a/apps/api/app/services/default_plans.ts
+++ b/apps/api/app/services/default_plans.ts
@@ -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 }
)
diff --git a/apps/api/app/services/demo/capture.ts b/apps/api/app/services/demo/capture.ts
index 5e735ba..2de1b67 100644
--- a/apps/api/app/services/demo/capture.ts
+++ b/apps/api/app/services/demo/capture.ts
@@ -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
diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts
index 26a3fde..ccc3058 100644
--- a/apps/api/app/services/mail_dispatcher.ts
+++ b/apps/api/app/services/mail_dispatcher.ts
@@ -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 {
+ 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
+ }
+}
diff --git a/apps/api/app/services/payment_thanks_dispatcher.ts b/apps/api/app/services/payment_thanks_dispatcher.ts
new file mode 100644
index 0000000..ada8afa
--- /dev/null
+++ b/apps/api/app/services/payment_thanks_dispatcher.ts
@@ -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 {
+ 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)')
+ }
+}
diff --git a/apps/api/app/transformers/plan_transformer.ts b/apps/api/app/transformers/plan_transformer.ts
index 6837cf7..2257bcf 100644
--- a/apps/api/app/transformers/plan_transformer.ts
+++ b/apps/api/app/transformers/plan_transformer.ts
@@ -27,6 +27,8 @@ export default class PlanTransformer extends BaseTransformer {
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()!,
}
diff --git a/apps/api/app/validators/plan.ts b/apps/api/app/validators/plan.ts
index 1946415..95a5ea9 100644
--- a/apps/api/app/validators/plan.ts
+++ b/apps/api/app/validators/plan.ts
@@ -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(),
})
diff --git a/apps/api/config/queue.ts b/apps/api/config/queue.ts
index e586687..502f0e1 100644
--- a/apps/api/config/queue.ts
+++ b/apps/api/config/queue.ts
@@ -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 = {
@@ -26,4 +26,5 @@ export const queueConcurrency: Record = {
relances: 5,
checkins: 5,
kpis: 1,
+ 'payment-thanks': 5,
}
diff --git a/apps/api/database/migrations/1778260000000_add_thanks_template_to_plans_table.ts b/apps/api/database/migrations/1778260000000_add_thanks_template_to_plans_table.ts
new file mode 100644
index 0000000..d536851
--- /dev/null
+++ b/apps/api/database/migrations/1778260000000_add_thanks_template_to_plans_table.ts
@@ -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')
+ })
+ }
+}
diff --git a/apps/api/database/migrations/1778260000100_add_thanks_email_sent_to_activity_event_kind.ts b/apps/api/database/migrations/1778260000100_add_thanks_email_sent_to_activity_event_kind.ts
new file mode 100644
index 0000000..7f979bc
--- /dev/null
+++ b/apps/api/database/migrations/1778260000100_add_thanks_email_sent_to_activity_event_kind.ts
@@ -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.
+ }
+}
diff --git a/apps/api/database/migrations/1778260000200_backfill_thanks_template_for_default_plans.ts b/apps/api/database/migrations/1778260000200_backfill_thanks_template_for_default_plans.ts
new file mode 100644
index 0000000..c3236bc
--- /dev/null
+++ b/apps/api/database/migrations/1778260000200_backfill_thanks_template_for_default_plans.ts
@@ -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().
+ }
+}
diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts
index b27e9c6..9c28f8a 100644
--- a/apps/api/database/schema.ts
+++ b/apps/api/database/schema.ts
@@ -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
diff --git a/apps/api/start/queue.ts b/apps/api/start/queue.ts
index 87abefb..a81284b 100644
--- a/apps/api/start/queue.ts
+++ b/apps/api/start/queue.ts
@@ -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')
diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx
index 91961e7..a461260 100644
--- a/apps/web/src/components/dashboard/ActivityFeed.tsx
+++ b/apps/web/src/components/dashboard/ActivityFeed.tsx
@@ -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 = {
invoice_paid: CheckCircle2,
invoice_imported: Inbox,
warning_drafted: AlertTriangle,
+ thanks_email_sent: MailCheck,
};
const TONE: Record = {
@@ -38,6 +47,7 @@ const TONE: Record = {
invoice_paid: "text-rubis-deep",
invoice_imported: "text-ink-2",
warning_drafted: "text-rubis-deep",
+ thanks_email_sent: "text-rubis",
};
type ActivityFeedProps = {
diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts
index da7a0bf..c546340 100644
--- a/apps/web/src/mocks/db.ts
+++ b/apps/web/src/mocks/db.ts
@@ -306,7 +306,9 @@ export const mockDb = {
updatePlan(
orgId: string,
id: string,
- patch: Partial>,
+ patch: Partial<
+ Pick
+ >,
): Plan | undefined {
const db = load();
const idx = db.plans.findIndex(
diff --git a/apps/web/src/mocks/handlers/plans.ts b/apps/web/src/mocks/handlers/plans.ts
index 0dc31a1..9834555 100644
--- a/apps/web/src/mocks/handlers/plans.ts
+++ b/apps/web/src/mocks/handlers/plans.ts
@@ -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 });
diff --git a/apps/web/src/mocks/seed.ts b/apps/web/src/mocks/seed.ts
index 37829da..fa9822a 100644
--- a/apps/web/src/mocks/seed.ts
+++ b/apps/web/src/mocks/seed.ts
@@ -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",
diff --git a/apps/web/src/routes/_app/plans_.$slug.tsx b/apps/web/src/routes/_app/plans_.$slug.tsx
index b9bdf33..e7e2220 100644
--- a/apps/web/src/routes/_app/plans_.$slug.tsx
+++ b/apps/web/src/routes/_app/plans_.$slug.tsx
@@ -57,17 +57,31 @@ function PlanEditorPage() {
const [selectedStepId, setSelectedStepId] = useState(null);
const bodyRef = useRef(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(null);
+ const [draftThanksBody, setDraftThanksBody] = useState(null);
+ const thanksBodyRef = useRef(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(`/api/v1/plans/${slug}`, { steps }),
+ mutationFn: (payload: SavePayload) =>
+ api.patch(`/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"}
@@ -301,6 +338,84 @@ function PlanEditorPage() {
)}
+
+ {/* === Email de remerciement (envoyé après confirmation de paiement) === */}
+
+ Email de remerciement
+
+
+ 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 »).
+
+
+
);
}
diff --git a/packages/shared/src/schemas/plan.ts b/packages/shared/src/schemas/plan.ts
index fa22abb..a0aa9be 100644
--- a/packages/shared/src/schemas/plan.ts
+++ b/packages/shared/src/schemas/plan.ts
@@ -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();
diff --git a/packages/shared/src/types/plan.ts b/packages/shared/src/types/plan.ts
index 9301ded..4407f76 100644
--- a/packages/shared/src/types/plan.ts
+++ b/packages/shared/src/types/plan.ts
@@ -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;
};