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 { hashCheckinToken } from '#services/checkin_token'
|
||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
||||||
|
import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
|
||||||
import * as clock from '#services/clock'
|
import * as clock from '#services/clock'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
@ -91,6 +92,11 @@ export default class CheckinController {
|
|||||||
}
|
}
|
||||||
const { task, invoice } = result
|
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) => {
|
await db.transaction(async (trx) => {
|
||||||
const nowOrg = await clock.now(invoice.organizationId)
|
const nowOrg = await clock.now(invoice.organizationId)
|
||||||
task.useTransaction(trx)
|
task.useTransaction(trx)
|
||||||
@ -100,7 +106,7 @@ export default class CheckinController {
|
|||||||
await task.save()
|
await task.save()
|
||||||
|
|
||||||
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
|
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
|
||||||
if (invoice.status !== 'paid') {
|
if (wasUnpaid) {
|
||||||
invoice.useTransaction(trx)
|
invoice.useTransaction(trx)
|
||||||
invoice.status = 'paid'
|
invoice.status = 'paid'
|
||||||
invoice.paidAt = nowOrg
|
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))
|
return response.redirect(spaRedirectUrl('paid', invoice))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,6 +210,8 @@ export default class CheckinController {
|
|||||||
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wasUnpaid = invoice.status !== 'paid'
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const nowOrg = await clock.now(invoice.organizationId)
|
const nowOrg = await clock.now(invoice.organizationId)
|
||||||
|
|
||||||
@ -215,7 +229,7 @@ export default class CheckinController {
|
|||||||
await task.save()
|
await task.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.status !== 'paid') {
|
if (wasUnpaid) {
|
||||||
invoice.useTransaction(trx)
|
invoice.useTransaction(trx)
|
||||||
invoice.status = 'paid'
|
invoice.status = 'paid'
|
||||||
invoice.paidAt = nowOrg
|
invoice.paidAt = nowOrg
|
||||||
@ -239,6 +253,10 @@ export default class CheckinController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (wasUnpaid) {
|
||||||
|
await enqueuePaymentThanks(invoice.id)
|
||||||
|
}
|
||||||
|
|
||||||
return response.json({ data: serializeInvoice(invoice) })
|
return response.json({ data: serializeInvoice(invoice) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { resolveClient } from '#services/resolve_client'
|
|||||||
import { recordActivity } from '#services/activity_recorder'
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import { cancelFutureRelances } from '#services/relance_scheduler'
|
import { cancelFutureRelances } from '#services/relance_scheduler'
|
||||||
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
|
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
|
||||||
|
import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher'
|
||||||
import { canCreateInvoices } from '#services/billing'
|
import { canCreateInvoices } from '#services/billing'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import * as clock from '#services/clock'
|
import * as clock from '#services/clock'
|
||||||
@ -434,6 +435,12 @@ export default class InvoicesController {
|
|||||||
await cancelCheckinForInvoice(invoice.id, trx)
|
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) })
|
return response.json({ data: serializeInvoice(invoice) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -156,6 +156,8 @@ export default class PlansController {
|
|||||||
plan.useTransaction(trx)
|
plan.useTransaction(trx)
|
||||||
if (payload.name !== undefined) plan.name = payload.name
|
if (payload.name !== undefined) plan.name = payload.name
|
||||||
if (payload.description !== undefined) plan.description = payload.description
|
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()
|
await plan.save()
|
||||||
|
|
||||||
if (payload.steps !== undefined) {
|
if (payload.steps !== undefined) {
|
||||||
@ -202,6 +204,8 @@ export default class PlansController {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
description: payload.description ?? '',
|
description: payload.description ?? '',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
|
thanksSubject: payload.thanksSubject ?? null,
|
||||||
|
thanksBody: payload.thanksBody ?? null,
|
||||||
},
|
},
|
||||||
{ client: trx }
|
{ 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 * as clock from '#services/clock'
|
||||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
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 = {
|
type RecordOpts = {
|
||||||
organizationId: string
|
organizationId: string
|
||||||
|
|||||||
@ -26,6 +26,13 @@ type DefaultPlan = {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
steps: DefaultStep[]
|
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[] = [
|
export const DEFAULT_PLANS: DefaultPlan[] = [
|
||||||
@ -34,6 +41,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
|
|||||||
name: 'Standard B2B',
|
name: 'Standard B2B',
|
||||||
description:
|
description:
|
||||||
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
|
'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: [
|
steps: [
|
||||||
{
|
{
|
||||||
order: 0,
|
order: 0,
|
||||||
@ -68,6 +78,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
|
|||||||
slug: 'rapide-15j',
|
slug: 'rapide-15j',
|
||||||
name: 'Rapide',
|
name: 'Rapide',
|
||||||
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
order: 0,
|
order: 0,
|
||||||
@ -99,6 +112,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
|
|||||||
slug: 'patient-60j',
|
slug: 'patient-60j',
|
||||||
name: 'Patient',
|
name: 'Patient',
|
||||||
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
order: 0,
|
order: 0,
|
||||||
@ -122,6 +138,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [
|
|||||||
slug: 'ferme-7j',
|
slug: 'ferme-7j',
|
||||||
name: 'Ferme',
|
name: 'Ferme',
|
||||||
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
order: 0,
|
order: 0,
|
||||||
@ -181,6 +200,8 @@ export async function provisionDefaultPlans(
|
|||||||
name: tpl.name,
|
name: tpl.name,
|
||||||
description: tpl.description,
|
description: tpl.description,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
thanksSubject: tpl.thanksSubject,
|
||||||
|
thanksBody: tpl.thanksBody,
|
||||||
},
|
},
|
||||||
{ client: trx }
|
{ client: trx }
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import Organization from '#models/organization'
|
|||||||
*/
|
*/
|
||||||
export type CaptureInput = {
|
export type CaptureInput = {
|
||||||
organizationId: string
|
organizationId: string
|
||||||
kind: 'relance' | 'checkin'
|
kind: 'relance' | 'checkin' | 'thanks'
|
||||||
to: { email: string; name?: string | null }
|
to: { email: string; name?: string | null }
|
||||||
from: { email: string; name?: string | null }
|
from: { email: string; name?: string | null }
|
||||||
replyTo?: string | null
|
replyTo?: string | null
|
||||||
|
|||||||
@ -8,12 +8,23 @@ import * as clock from '#services/clock'
|
|||||||
import { captureEmailIfDemo } from '#services/demo/capture'
|
import { captureEmailIfDemo } from '#services/demo/capture'
|
||||||
import type Invoice from '#models/invoice'
|
import type Invoice from '#models/invoice'
|
||||||
import type Client from '#models/client'
|
import type Client from '#models/client'
|
||||||
|
import type Plan from '#models/plan'
|
||||||
import type PlanStep from '#models/plan_step'
|
import type PlanStep from '#models/plan_step'
|
||||||
import type User from '#models/user'
|
import type User from '#models/user'
|
||||||
import type Organization from '#models/organization'
|
import type Organization from '#models/organization'
|
||||||
|
|
||||||
import { CheckinEmail } from '#mails/checkin_email'
|
import { CheckinEmail } from '#mails/checkin_email'
|
||||||
import { RelanceEmail } from '#mails/relance_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 = {
|
type RelancePayload = {
|
||||||
invoice: Invoice
|
invoice: Invoice
|
||||||
@ -287,3 +298,130 @@ L'équipe Rubis`
|
|||||||
.text(body)
|
.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,
|
description: p.description,
|
||||||
isDefault: p.isDefault,
|
isDefault: p.isDefault,
|
||||||
steps: steps.map(serializeStep),
|
steps: steps.map(serializeStep),
|
||||||
|
thanksSubject: p.thanksSubject,
|
||||||
|
thanksBody: p.thanksBody,
|
||||||
createdAt: p.createdAt.toISO()!,
|
createdAt: p.createdAt.toISO()!,
|
||||||
updatedAt: p.updatedAt?.toISO() ?? 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
|
* Validator pour PATCH /plans/:slug. Tous les champs optionnels — l'éditeur
|
||||||
* front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie.
|
* 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({
|
export const updatePlanValidator = vine.create({
|
||||||
name: vine.string().minLength(1).maxLength(80).optional(),
|
name: vine.string().minLength(1).maxLength(80).optional(),
|
||||||
description: vine.string().maxLength(500).optional(),
|
description: vine.string().maxLength(500).optional(),
|
||||||
steps: vine.array(planStep).minLength(1).maxLength(10).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),
|
name: vine.string().minLength(1).maxLength(80),
|
||||||
description: vine.string().maxLength(500).optional(),
|
description: vine.string().maxLength(500).optional(),
|
||||||
steps: vine.array(planStep).minLength(1).maxLength(10),
|
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.
|
* Liste des queues. La concurrence est appliquée côté worker.
|
||||||
* Ajouter une queue ici → ajouter un Worker correspondant dans #start/queue.ts.
|
* 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 type QueueName = (typeof queueNames)[number]
|
||||||
|
|
||||||
export const queueConcurrency: Record<QueueName, number> = {
|
export const queueConcurrency: Record<QueueName, number> = {
|
||||||
@ -26,4 +26,5 @@ export const queueConcurrency: Record<QueueName, number> = {
|
|||||||
relances: 5,
|
relances: 5,
|
||||||
checkins: 5,
|
checkins: 5,
|
||||||
kpis: 1,
|
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 })
|
@column({ isPrimary: true })
|
||||||
declare id: string
|
declare id: string
|
||||||
@column()
|
@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()
|
@column()
|
||||||
declare label: string
|
declare label: string
|
||||||
@column()
|
@column()
|
||||||
@ -286,7 +286,7 @@ export class PlanStepSchema extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PlanSchema 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
|
$columns = PlanSchema.$columns
|
||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
@ -302,6 +302,10 @@ export class PlanSchema extends BaseModel {
|
|||||||
declare organizationId: string
|
declare organizationId: string
|
||||||
@column()
|
@column()
|
||||||
declare slug: string | null
|
declare slug: string | null
|
||||||
|
@column()
|
||||||
|
declare thanksBody: string | null
|
||||||
|
@column()
|
||||||
|
declare thanksSubject: string | null
|
||||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
declare updatedAt: DateTime | null
|
declare updatedAt: DateTime | null
|
||||||
}
|
}
|
||||||
@ -404,7 +408,7 @@ export class RelanceTaskSchema extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UserSchema 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
|
$columns = UserSchema.$columns
|
||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
@ -417,6 +421,8 @@ export class UserSchema extends BaseModel {
|
|||||||
@column({ isPrimary: true })
|
@column({ isPrimary: true })
|
||||||
declare id: string
|
declare id: string
|
||||||
@column()
|
@column()
|
||||||
|
declare isAdmin: boolean
|
||||||
|
@column()
|
||||||
declare microsoftId: string | null
|
declare microsoftId: string | null
|
||||||
@column()
|
@column()
|
||||||
declare organizationId: string | null
|
declare organizationId: string | null
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import logger from '@adonisjs/core/services/logger'
|
|||||||
import { registerWorker, shutdownQueue } from '#services/queue'
|
import { registerWorker, shutdownQueue } from '#services/queue'
|
||||||
import { sendRelanceJob } from '#jobs/send_relance_job'
|
import { sendRelanceJob } from '#jobs/send_relance_job'
|
||||||
import { sendCheckinJob } from '#jobs/send_checkin_job'
|
import { sendCheckinJob } from '#jobs/send_checkin_job'
|
||||||
|
import { sendPaymentThanksJob } from '#jobs/send_payment_thanks_job'
|
||||||
|
|
||||||
if (app.getEnvironment() === 'web') {
|
if (app.getEnvironment() === 'web') {
|
||||||
try {
|
try {
|
||||||
@ -30,7 +31,11 @@ if (app.getEnvironment() === 'web') {
|
|||||||
await sendCheckinJob(job.data)
|
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 () => {
|
app.terminating(async () => {
|
||||||
logger.info('shutting down BullMQ workers')
|
logger.info('shutting down BullMQ workers')
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
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 { Card } from "@rubis/ui";
|
||||||
import { Eyebrow } from "@rubis/ui";
|
import { Eyebrow } from "@rubis/ui";
|
||||||
@ -15,7 +22,8 @@ export type ActivityKind =
|
|||||||
| "relance_sent"
|
| "relance_sent"
|
||||||
| "invoice_paid"
|
| "invoice_paid"
|
||||||
| "invoice_imported"
|
| "invoice_imported"
|
||||||
| "warning_drafted";
|
| "warning_drafted"
|
||||||
|
| "thanks_email_sent";
|
||||||
|
|
||||||
export type ActivityEvent = {
|
export type ActivityEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -31,6 +39,7 @@ const ICONS: Record<ActivityKind, LucideIcon> = {
|
|||||||
invoice_paid: CheckCircle2,
|
invoice_paid: CheckCircle2,
|
||||||
invoice_imported: Inbox,
|
invoice_imported: Inbox,
|
||||||
warning_drafted: AlertTriangle,
|
warning_drafted: AlertTriangle,
|
||||||
|
thanks_email_sent: MailCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TONE: Record<ActivityKind, string> = {
|
const TONE: Record<ActivityKind, string> = {
|
||||||
@ -38,6 +47,7 @@ const TONE: Record<ActivityKind, string> = {
|
|||||||
invoice_paid: "text-rubis-deep",
|
invoice_paid: "text-rubis-deep",
|
||||||
invoice_imported: "text-ink-2",
|
invoice_imported: "text-ink-2",
|
||||||
warning_drafted: "text-rubis-deep",
|
warning_drafted: "text-rubis-deep",
|
||||||
|
thanks_email_sent: "text-rubis",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActivityFeedProps = {
|
type ActivityFeedProps = {
|
||||||
|
|||||||
@ -306,7 +306,9 @@ export const mockDb = {
|
|||||||
updatePlan(
|
updatePlan(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
id: string,
|
id: string,
|
||||||
patch: Partial<Pick<Plan, "name" | "description" | "steps">>,
|
patch: Partial<
|
||||||
|
Pick<Plan, "name" | "description" | "steps" | "thanksSubject" | "thanksBody">
|
||||||
|
>,
|
||||||
): Plan | undefined {
|
): Plan | undefined {
|
||||||
const db = load();
|
const db = load();
|
||||||
const idx = db.plans.findIndex(
|
const idx = db.plans.findIndex(
|
||||||
|
|||||||
@ -41,12 +41,16 @@ const updatePlanSchema = z.object({
|
|||||||
name: z.string().min(1).max(80).optional(),
|
name: z.string().min(1).max(80).optional(),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
steps: z.array(updatePlanStepSchema).min(1).max(10).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({
|
const createPlanSchema = z.object({
|
||||||
name: z.string().min(1).max(80),
|
name: z.string().min(1).max(80),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
steps: z.array(updatePlanStepSchema).min(1).max(10),
|
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"]);
|
const RESERVED_SLUGS = new Set(["nouveau", "new", "create"]);
|
||||||
@ -141,6 +145,8 @@ export const planHandlers = [
|
|||||||
...s,
|
...s,
|
||||||
id: s.id ?? `step_${planId}_${idx}_${Date.now()}`,
|
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 });
|
return HttpResponse.json({ data: created }, { status: 201 });
|
||||||
}),
|
}),
|
||||||
@ -181,6 +187,12 @@ export const planHandlers = [
|
|||||||
description: parsed.data.description,
|
description: parsed.data.description,
|
||||||
}),
|
}),
|
||||||
...(steps !== undefined && { steps }),
|
...(steps !== undefined && { steps }),
|
||||||
|
...(parsed.data.thanksSubject !== undefined && {
|
||||||
|
thanksSubject: parsed.data.thanksSubject,
|
||||||
|
}),
|
||||||
|
...(parsed.data.thanksBody !== undefined && {
|
||||||
|
thanksBody: parsed.data.thanksBody,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return HttpResponse.json({ data: updated });
|
return HttpResponse.json({ data: updated });
|
||||||
|
|||||||
@ -99,6 +99,9 @@ export const SEED_PLANS: Plan[] = [
|
|||||||
name: "Standard B2B",
|
name: "Standard B2B",
|
||||||
description: "Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.",
|
description: "Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.",
|
||||||
isDefault: true,
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: "step_std_1",
|
id: "step_std_1",
|
||||||
@ -138,6 +141,9 @@ export const SEED_PLANS: Plan[] = [
|
|||||||
name: "Rapide",
|
name: "Rapide",
|
||||||
description: "Cadence resserrée pour les factures récurrentes ou les délais courts.",
|
description: "Cadence resserrée pour les factures récurrentes ou les délais courts.",
|
||||||
isDefault: true,
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: "step_rap_1",
|
id: "step_rap_1",
|
||||||
@ -177,6 +183,9 @@ export const SEED_PLANS: Plan[] = [
|
|||||||
name: "Patient",
|
name: "Patient",
|
||||||
description: "Pour les clients de longue date. On laisse respirer avant de relancer.",
|
description: "Pour les clients de longue date. On laisse respirer avant de relancer.",
|
||||||
isDefault: true,
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: "step_pat_1",
|
id: "step_pat_1",
|
||||||
@ -207,6 +216,9 @@ export const SEED_PLANS: Plan[] = [
|
|||||||
name: "Ferme",
|
name: "Ferme",
|
||||||
description: "Cadence stricte pour les clients à risque ou les retards récurrents.",
|
description: "Cadence stricte pour les clients à risque ou les retards récurrents.",
|
||||||
isDefault: true,
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: "step_fer_1",
|
id: "step_fer_1",
|
||||||
|
|||||||
@ -57,17 +57,31 @@ function PlanEditorPage() {
|
|||||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||||
const bodyRef = useRef<HTMLTextAreaElement | 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
|
// 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.
|
// local. On évite les races avec une clé sur plan.id+updatedAt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
setDraftSteps(plan.steps);
|
setDraftSteps(plan.steps);
|
||||||
setSelectedStepId((current) => current ?? plan.steps[0]?.id ?? null);
|
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
|
}, [plan?.id, plan?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
type SavePayload = {
|
||||||
|
steps: PlanStep[];
|
||||||
|
thanksSubject: string | null;
|
||||||
|
thanksBody: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (steps: PlanStep[]) =>
|
mutationFn: (payload: SavePayload) =>
|
||||||
api.patch<Plan>(`/api/v1/plans/${slug}`, { steps }),
|
api.patch<Plan>(`/api/v1/plans/${slug}`, payload),
|
||||||
onSuccess: (saved) => {
|
onSuccess: (saved) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
|
||||||
void queryClient.setQueryData(
|
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 =
|
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 });
|
const mood = planMoodLabel({ steps: draftSteps });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -173,7 +204,13 @@ function PlanEditorPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!isDirty}
|
disabled={!isDirty}
|
||||||
loading={saveMutation.isPending}
|
loading={saveMutation.isPending}
|
||||||
onClick={() => saveMutation.mutate(draftSteps)}
|
onClick={() =>
|
||||||
|
saveMutation.mutate({
|
||||||
|
steps: draftSteps,
|
||||||
|
thanksSubject: draftThanksSubject,
|
||||||
|
thanksBody: draftThanksBody,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isDirty ? "Enregistrer" : "Aucune modification"}
|
{isDirty ? "Enregistrer" : "Aucune modification"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -301,6 +338,84 @@ function PlanEditorPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,10 @@ export const createPlanSchema = z.object({
|
|||||||
.array(planStepSchema)
|
.array(planStepSchema)
|
||||||
.min(1, "Au moins une étape")
|
.min(1, "Au moins une étape")
|
||||||
.max(10, "Pas plus de 10 étapes — on reste raisonnable"),
|
.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();
|
export const updatePlanSchema = createPlanSchema.partial();
|
||||||
|
|||||||
@ -30,6 +30,14 @@ export type Plan = {
|
|||||||
description: string;
|
description: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
steps: PlanStep[];
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user