feat: email de remerciement automatique après confirmation de paiement
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:
parent
2b34388723
commit
77fdb6af48
@ -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) })
|
||||
}
|
||||
|
||||
|
||||
@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
64
apps/api/app/jobs/send_payment_thanks_job.ts
Normal file
64
apps/api/app/jobs/send_payment_thanks_job.ts
Normal 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 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é à <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'
|
||||
)
|
||||
}
|
||||
136
apps/api/app/mails/payment_thanks_email.tsx
Normal file
136
apps/api/app/mails/payment_thanks_email.tsx
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
56
apps/api/app/services/payment_thanks_dispatcher.ts
Normal file
56
apps/api/app/services/payment_thanks_dispatcher.ts
Normal 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)')
|
||||
}
|
||||
}
|
||||
@ -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()!,
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
@ -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().
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user