diff --git a/apps/api/app/controllers/checkin_controller.ts b/apps/api/app/controllers/checkin_controller.ts index a115d0f..287fa09 100644 --- a/apps/api/app/controllers/checkin_controller.ts +++ b/apps/api/app/controllers/checkin_controller.ts @@ -4,6 +4,7 @@ import InvoiceTransformer from '#transformers/invoice_transformer' import { hashCheckinToken } from '#services/checkin_token' import { recordActivity } from '#services/activity_recorder' import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler' +import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher' import * as clock from '#services/clock' import db from '@adonisjs/lucid/services/db' import env from '#start/env' @@ -91,6 +92,11 @@ export default class CheckinController { } const { task, invoice } = result + // Capture la transition `* → paid` AVANT le commit pour décider d'enqueuer + // le remerciement (idempotence : si la facture était déjà payée, on + // n'envoie pas un 2e thanks). + const wasUnpaid = invoice.status !== 'paid' + await db.transaction(async (trx) => { const nowOrg = await clock.now(invoice.organizationId) task.useTransaction(trx) @@ -100,7 +106,7 @@ export default class CheckinController { await task.save() // Mark paid (mêmes effets que POST /invoices/:id/mark-paid). - if (invoice.status !== 'paid') { + if (wasUnpaid) { invoice.useTransaction(trx) invoice.status = 'paid' invoice.paidAt = nowOrg @@ -124,6 +130,12 @@ export default class CheckinController { } }) + // Enqueue après le commit — l'envoi est asynchrone, sans bloquer le + // redirect SPA. Skippé silencieusement si Redis est down. + if (wasUnpaid) { + await enqueuePaymentThanks(invoice.id) + } + return response.redirect(spaRedirectUrl('paid', invoice)) } @@ -198,6 +210,8 @@ export default class CheckinController { throw new Exception('Facture introuvable', { status: 404, code: 'not_found' }) } + const wasUnpaid = invoice.status !== 'paid' + await db.transaction(async (trx) => { const nowOrg = await clock.now(invoice.organizationId) @@ -215,7 +229,7 @@ export default class CheckinController { await task.save() } - if (invoice.status !== 'paid') { + if (wasUnpaid) { invoice.useTransaction(trx) invoice.status = 'paid' invoice.paidAt = nowOrg @@ -239,6 +253,10 @@ export default class CheckinController { } }) + if (wasUnpaid) { + await enqueuePaymentThanks(invoice.id) + } + return response.json({ data: serializeInvoice(invoice) }) } diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 655e3d8..4481f1a 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -11,6 +11,7 @@ import { resolveClient } from '#services/resolve_client' import { recordActivity } from '#services/activity_recorder' import { cancelFutureRelances } from '#services/relance_scheduler' import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler' +import { enqueuePaymentThanks } from '#services/payment_thanks_dispatcher' import { canCreateInvoices } from '#services/billing' import logger from '@adonisjs/core/services/logger' import * as clock from '#services/clock' @@ -434,6 +435,12 @@ export default class InvoicesController { await cancelCheckinForInvoice(invoice.id, trx) }) + // Enqueue le mail de remerciement après commit. Cohérent avec le flow + // check-in : mark-paid manuel = même intention utilisateur ("j'ai été payé"). + // L'early-return en haut de la méthode (idempotence si déjà payée) garantit + // qu'on n'arrive ici que sur transition réelle * → paid. + await enqueuePaymentThanks(invoice.id) + return response.json({ data: serializeInvoice(invoice) }) } } diff --git a/apps/api/app/controllers/plans_controller.ts b/apps/api/app/controllers/plans_controller.ts index 1dc582b..d3c009f 100644 --- a/apps/api/app/controllers/plans_controller.ts +++ b/apps/api/app/controllers/plans_controller.ts @@ -156,6 +156,8 @@ export default class PlansController { plan.useTransaction(trx) if (payload.name !== undefined) plan.name = payload.name if (payload.description !== undefined) plan.description = payload.description + if (payload.thanksSubject !== undefined) plan.thanksSubject = payload.thanksSubject + if (payload.thanksBody !== undefined) plan.thanksBody = payload.thanksBody await plan.save() if (payload.steps !== undefined) { @@ -202,6 +204,8 @@ export default class PlansController { name: payload.name, description: payload.description ?? '', isDefault: false, + thanksSubject: payload.thanksSubject ?? null, + thanksBody: payload.thanksBody ?? null, }, { client: trx } ) diff --git a/apps/api/app/jobs/send_payment_thanks_job.ts b/apps/api/app/jobs/send_payment_thanks_job.ts new file mode 100644 index 0000000..94b9abf --- /dev/null +++ b/apps/api/app/jobs/send_payment_thanks_job.ts @@ -0,0 +1,64 @@ +import Invoice from '#models/invoice' +import User from '#models/user' +import Organization from '#models/organization' +import { sendPaymentThanksEmail } from '#services/mail_dispatcher' +import { recordActivity } from '#services/activity_recorder' +import logger from '@adonisjs/core/services/logger' + +/** + * Worker BullMQ pour la queue `payment-thanks`. Idempotent au sens où + * le caller (controllers) n'enqueue que sur transition `* → paid` ; le job + * lui-même se contente de loader la facture et d'envoyer. + * + * Cas no-op silencieux (pas d'erreur — le job réussit) : + * - facture introuvable / supprimée + * - client sans email (déjà loggué dans sendPaymentThanksEmail) + * - utilisateur sans org (anomalie data) + */ +export async function sendPaymentThanksJob(jobData: { invoiceId: string }) { + logger.info({ invoiceId: jobData.invoiceId }, 'sendPaymentThanksJob: pick-up') + + const invoice = await Invoice.query() + .where('id', jobData.invoiceId) + .preload('client') + .preload('plan') + .preload('organization') + .first() + if (!invoice) { + logger.warn({ invoiceId: jobData.invoiceId }, 'sendPaymentThanksJob: invoice not found, skip') + return + } + if (!invoice.client?.email) { + logger.warn( + { invoiceId: invoice.id, numero: invoice.numero }, + 'sendPaymentThanksJob: client sans email, skip' + ) + return + } + + const user = await User.query().where('organization_id', invoice.organizationId).first() + // organization preloaded ci-dessus ; fallback explicite si la relation est null + // (anomalie data) — on continue avec null, le sender utilisera son fallback brand. + const organization = + invoice.organization ?? (await Organization.find(invoice.organizationId)) ?? null + + await sendPaymentThanksEmail({ + invoice, + client: invoice.client, + plan: invoice.plan ?? null, + user: user ?? null, + organization, + }) + + await recordActivity({ + organizationId: invoice.organizationId, + kind: 'thanks_email_sent', + label: `Remerciement envoyé à ${invoice.client.name} pour ${invoice.numero}`, + meta: { invoiceId: invoice.id, clientId: invoice.clientId }, + }) + + logger.info( + { invoiceId: invoice.id, numero: invoice.numero }, + 'sendPaymentThanksJob: terminé OK' + ) +} diff --git a/apps/api/app/mails/payment_thanks_email.tsx b/apps/api/app/mails/payment_thanks_email.tsx new file mode 100644 index 0000000..916d2e6 --- /dev/null +++ b/apps/api/app/mails/payment_thanks_email.tsx @@ -0,0 +1,136 @@ +/** + * Template email de remerciement — envoyé AU CLIENT FINAL après que + * l'utilisateur a confirmé le paiement (via check-in « Oui, payé » ou + * mark-paid manuel). + * + * Mise en page volontairement plus douce que la relance : + * - bandeau header rubis-deep avec un check ✓ pour signaler "tout est OK" + * - body interpolé (le user a écrit le mot, on garde sa voix) + * - card récap discrète : facture, montant — pas de date d'échéance ni + * de retard, on est passé à autre chose. + */ + +import * as React from 'react' +import { Section, Text } from '@react-email/components' + +import { BRAND, sp } from './_brand.js' +import { EmailLayout } from './_layout.js' + +export type PaymentThanksEmailProps = { + /** Nom commercial visible côté client (l'org du user). */ + brandName: string + invoice: { + numero: string + amountFormatted: string + } + /** Texte de remerciement (déjà interpolé) — le mot du user. */ + bodyText: string + /** URL landing publique (footer cliquable « Rubis sur l'ongle »). */ + landingUrl?: string +} + +export function PaymentThanksEmail({ + brandName, + invoice, + bodyText, + landingUrl, +}: PaymentThanksEmailProps) { + return ( + + {/* Petit check visuel pour signaler positivement "c'est bon". */} +
+ +
+ + {bodyText} + +
+ + Facture + {invoice.numero} + + + Montant TTC + + {invoice.amountFormatted} + + + + Statut + Réglée + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Styles inline +// --------------------------------------------------------------------------- + +const checkBadgeWrapStyle: React.CSSProperties = { + textAlign: 'center', + margin: `0 0 ${sp.lg} 0`, +} + +const checkBadgeStyle: React.CSSProperties = { + display: 'inline-block', + width: '44px', + height: '44px', + lineHeight: '44px', + textAlign: 'center', + borderRadius: '999px', + backgroundColor: BRAND.rubisGlow, + color: BRAND.rubisDeep, + fontSize: '22px', + fontWeight: 800, +} + +const bodyTextStyle: React.CSSProperties = { + color: BRAND.ink, + fontSize: '15px', + lineHeight: '1.6', + margin: `0 0 ${sp.xl} 0`, + whiteSpace: 'pre-line', +} + +const summaryCardStyle: React.CSSProperties = { + backgroundColor: BRAND.white, + border: `1px solid ${BRAND.line}`, + borderRadius: BRAND.radiusCard, + padding: `${sp.md} ${sp.lg}`, + margin: `${sp.lg} 0 0 0`, +} + +const summaryRowStyle: React.CSSProperties = { + display: 'block', + margin: `${sp.sm} 0`, + fontSize: '13px', + lineHeight: '1.4', +} + +const summaryLabelStyle: React.CSSProperties = { + display: 'inline-block', + width: '110px', + color: BRAND.ink3, + fontWeight: 500, +} + +const summaryValueStyle: React.CSSProperties = { + color: BRAND.ink, + fontWeight: 600, +} diff --git a/apps/api/app/services/activity_recorder.ts b/apps/api/app/services/activity_recorder.ts index fc631b8..277a6c5 100644 --- a/apps/api/app/services/activity_recorder.ts +++ b/apps/api/app/services/activity_recorder.ts @@ -3,7 +3,12 @@ import ActivityEvent from '#models/activity_event' import * as clock from '#services/clock' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' -type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' +type EventKind = + | 'relance_sent' + | 'invoice_paid' + | 'invoice_imported' + | 'warning_drafted' + | 'thanks_email_sent' type RecordOpts = { organizationId: string diff --git a/apps/api/app/services/default_plans.ts b/apps/api/app/services/default_plans.ts index 6708457..e1bd066 100644 --- a/apps/api/app/services/default_plans.ts +++ b/apps/api/app/services/default_plans.ts @@ -26,6 +26,13 @@ type DefaultPlan = { name: string description: string steps: DefaultStep[] + /** + * Email de remerciement envoyé au client final dès que l'utilisateur + * confirme avoir reçu le paiement (via check-in ou mark-paid). Mêmes + * variables que les steps (cf. mail_dispatcher → buildRelanceVars). + */ + thanksSubject: string + thanksBody: string } export const DEFAULT_PLANS: DefaultPlan[] = [ @@ -34,6 +41,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [ name: 'Standard B2B', description: 'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.', + thanksSubject: 'Merci ! Bien reçu pour la facture {{numero}}', + thanksBody: + "Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement et au plaisir de continuer à travailler ensemble.\n\n{{signature}}", steps: [ { order: 0, @@ -68,6 +78,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [ slug: 'rapide-15j', name: 'Rapide', description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.', + thanksSubject: 'Paiement bien reçu — facture {{numero}}', + thanksBody: + 'Bonjour {{client.name}},\n\nMerci, nous avons bien reçu le règlement de la facture {{numero}} ({{amount}}). Bonne continuation.\n\n{{signature}}', steps: [ { order: 0, @@ -99,6 +112,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [ slug: 'patient-60j', name: 'Patient', description: 'Pour les clients de longue date. On laisse respirer avant de relancer.', + thanksSubject: 'Merci — règlement bien reçu pour {{numero}}', + thanksBody: + "Bonjour {{client.name}},\n\nNous accusons bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci de votre confiance, à très bientôt.\n\n{{signature}}", steps: [ { order: 0, @@ -122,6 +138,9 @@ export const DEFAULT_PLANS: DefaultPlan[] = [ slug: 'ferme-7j', name: 'Ferme', description: 'Cadence stricte pour les clients à risque ou les retards récurrents.', + thanksSubject: 'Règlement reçu — facture {{numero}}', + thanksBody: + 'Bonjour {{client.name}},\n\nNous confirmons la bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci.\n\n{{signature}}', steps: [ { order: 0, @@ -181,6 +200,8 @@ export async function provisionDefaultPlans( name: tpl.name, description: tpl.description, isDefault: true, + thanksSubject: tpl.thanksSubject, + thanksBody: tpl.thanksBody, }, { client: trx } ) diff --git a/apps/api/app/services/demo/capture.ts b/apps/api/app/services/demo/capture.ts index 5e735ba..2de1b67 100644 --- a/apps/api/app/services/demo/capture.ts +++ b/apps/api/app/services/demo/capture.ts @@ -17,7 +17,7 @@ import Organization from '#models/organization' */ export type CaptureInput = { organizationId: string - kind: 'relance' | 'checkin' + kind: 'relance' | 'checkin' | 'thanks' to: { email: string; name?: string | null } from: { email: string; name?: string | null } replyTo?: string | null diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts index 26a3fde..ccc3058 100644 --- a/apps/api/app/services/mail_dispatcher.ts +++ b/apps/api/app/services/mail_dispatcher.ts @@ -8,12 +8,23 @@ import * as clock from '#services/clock' import { captureEmailIfDemo } from '#services/demo/capture' import type Invoice from '#models/invoice' import type Client from '#models/client' +import type Plan from '#models/plan' import type PlanStep from '#models/plan_step' import type User from '#models/user' import type Organization from '#models/organization' import { CheckinEmail } from '#mails/checkin_email' import { RelanceEmail } from '#mails/relance_email' +import { PaymentThanksEmail } from '#mails/payment_thanks_email' + +/** + * Templates par défaut utilisés quand le plan d'une org n'a pas (ou plus) + * de `thanksSubject` / `thanksBody` posé. On préfère un envoi un peu + * générique à un envoi raté — l'activation est systématique en V1. + */ +export const FALLBACK_THANKS_SUBJECT = 'Paiement bien reçu — facture {{numero}}' +export const FALLBACK_THANKS_BODY = + "Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement.\n\n{{signature}}" type RelancePayload = { invoice: Invoice @@ -287,3 +298,130 @@ L'équipe Rubis` .text(body) }) } + +type PaymentThanksPayload = { + invoice: Invoice + client: Client + /** Plan associé à la facture. null = plan supprimé / non rattaché → fallback. */ + plan: Plan | null + user: User | null + organization?: Organization | null +} + +/** + * Envoie un email de remerciement AU CLIENT FINAL après que l'utilisateur + * a confirmé le paiement (check-in « Oui, payé » ou mark-paid manuel). + * + * Subject + body proviennent du plan (`thanksSubject` / `thanksBody`), + * avec un fallback hardcodé si l'utilisateur a un plan custom sans + * template défini. Mêmes variables que les relances (cf. `buildRelanceVars`). + * + * Skip silencieusement si : + * - le client n'a pas d'email (no-op + log warn) + * - l'org est en démo (capture dans `demo_captured_emails`) + */ +export async function sendPaymentThanksEmail({ + invoice, + client, + plan, + user, + organization, +}: PaymentThanksPayload): Promise { + if (!client.email) { + logger.warn( + { invoiceId: invoice.id, numero: invoice.numero, clientId: client.id }, + 'sendPaymentThanksEmail: client sans email, skip' + ) + return + } + + const vars = buildRelanceVars({ + invoice, + client, + user, + organization, + now: await clock.now(invoice.organizationId), + }) + + const subjectTpl = plan?.thanksSubject ?? FALLBACK_THANKS_SUBJECT + const bodyTpl = plan?.thanksBody ?? FALLBACK_THANKS_BODY + const subject = renderTemplate(subjectTpl, vars) + const body = renderTemplate(bodyTpl, vars) + + const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro') + // Le client connaît l'org du user (ex: « Arthur Barré »), pas Rubis. + // Aligné avec sendRelanceEmail. + const fromName = + organization?.name?.trim() || + user?.fullName?.trim() || + env.get('MAIL_FROM_NAME', "Rubis sur l'ongle") + + const landingUrl = env.get('LANDING_URL', 'https://rubis.pro') + + const htmlBody = await render( + PaymentThanksEmail({ + brandName: fromName, + invoice: { + numero: invoice.numero, + amountFormatted: formatAmountFr(invoice.amountTtcCents), + }, + bodyText: body, + landingUrl, + }) + ) + + // FORK DÉMO — capture si demoMode (cf. sendRelanceEmail). + const captured = await captureEmailIfDemo({ + organizationId: invoice.organizationId, + kind: 'thanks', + to: { email: client.email, name: client.name }, + from: { email: fromAddress, name: fromName }, + replyTo: user?.email ?? null, + subject, + body, + meta: { invoiceId: invoice.id, clientId: client.id, planId: plan?.id ?? null }, + }) + if (captured) { + logger.info( + { invoiceId: invoice.id, numero: invoice.numero, to: client.email }, + "sendPaymentThanksEmail: capturé en mode démo (pas d'envoi réel)" + ) + return + } + + const driver = env.get('MAIL_DRIVER', 'smtp') + logger.info( + { + invoiceId: invoice.id, + numero: invoice.numero, + to: client.email, + from: fromAddress, + driver, + subjectPreview: subject.slice(0, 80), + }, + 'sendPaymentThanksEmail: envoi via driver' + ) + try { + const mailer = mail.use(driver) + await mailer.send((m) => { + m.from(fromAddress, fromName) + .to(client.email, client.name) + .subject(subject) + .html(htmlBody) + .text(body) + if (user?.email) { + m.replyTo(user.email, user.fullName ?? user.email) + } + }) + logger.info( + { invoiceId: invoice.id, numero: invoice.numero, driver }, + 'sendPaymentThanksEmail: send OK' + ) + } catch (err) { + logger.error( + { err, invoiceId: invoice.id, numero: invoice.numero, driver }, + 'sendPaymentThanksEmail: échec envoi' + ) + throw err + } +} diff --git a/apps/api/app/services/payment_thanks_dispatcher.ts b/apps/api/app/services/payment_thanks_dispatcher.ts new file mode 100644 index 0000000..ada8afa --- /dev/null +++ b/apps/api/app/services/payment_thanks_dispatcher.ts @@ -0,0 +1,56 @@ +import { getQueue } from '#services/queue' +import app from '@adonisjs/core/services/app' +import logger from '@adonisjs/core/services/logger' + +const PAYMENT_THANKS_QUEUE = 'payment-thanks' + +/** + * En tests, on skip l'enqueue BullMQ — les transactions auto-rollback + * laisseraient des jobs orphelins en Redis sinon, et on ne dépend pas + * d'une instance Redis live pour rouler les tests. Aligné avec le pattern + * de `relance_scheduler.shouldEnqueue()`. + */ +function shouldEnqueue(): boolean { + return app.getEnvironment() !== 'test' +} + +/** + * Enqueue l'envoi de l'email de remerciement pour une facture donnée. + * + * À appeler **après commit** de la transaction qui transitionne la facture + * vers `paid` (check-in confirm, mark-paid manuel). Les controllers gardent + * la responsabilité de l'idempotence : on n'enqueue que sur transition réelle + * `* → paid`, pas si la facture était déjà payée. + * + * Le job tourne avec un retry exponentiel (5 tentatives, backoff 30s) : si + * Resend / Mailpit est down, on retente. Échec définitif → log via le hook + * `worker.on('failed')` côté queue.ts. + */ +export async function enqueuePaymentThanks(invoiceId: string): Promise { + if (!shouldEnqueue()) return + + try { + const queue = getQueue(PAYMENT_THANKS_QUEUE) + await queue.add( + 'send-thanks', + { invoiceId }, + { + // BullMQ 5+ interdit `:` dans les custom jobIds → tiret. Idempotency + // via jobId : 2 enqueue concurrents pour la même invoice → un seul job. + jobId: `thanks-${invoiceId}`, + attempts: 5, + backoff: { type: 'exponential', delay: 30_000 }, + // L'email est non-critique : on ne le rejoue pas indéfiniment, et on + // garde une trace des derniers jobs failés pour debug. + removeOnComplete: { age: 24 * 3600, count: 1000 }, + removeOnFail: { age: 7 * 24 * 3600 }, + } + ) + logger.info({ invoiceId }, 'enqueuePaymentThanks: job enqueué') + } catch (err) { + // Ne pas faire échouer la requête HTTP du user pour un problème Redis. + // Le mark-paid a réussi en DB, c'est l'essentiel ; le remerciement est un + // « nice to have » qu'on pourra rattraper manuellement si besoin. + logger.error({ err, invoiceId }, 'enqueuePaymentThanks: échec enqueue (no-op)') + } +} diff --git a/apps/api/app/transformers/plan_transformer.ts b/apps/api/app/transformers/plan_transformer.ts index 6837cf7..2257bcf 100644 --- a/apps/api/app/transformers/plan_transformer.ts +++ b/apps/api/app/transformers/plan_transformer.ts @@ -27,6 +27,8 @@ export default class PlanTransformer extends BaseTransformer { description: p.description, isDefault: p.isDefault, steps: steps.map(serializeStep), + thanksSubject: p.thanksSubject, + thanksBody: p.thanksBody, createdAt: p.createdAt.toISO()!, updatedAt: p.updatedAt?.toISO() ?? p.createdAt.toISO()!, } diff --git a/apps/api/app/validators/plan.ts b/apps/api/app/validators/plan.ts index 1946415..95a5ea9 100644 --- a/apps/api/app/validators/plan.ts +++ b/apps/api/app/validators/plan.ts @@ -18,11 +18,16 @@ const planStep = vine.object({ /** * Validator pour PATCH /plans/:slug. Tous les champs optionnels — l'éditeur * front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie. + * + * `thanksSubject` / `thanksBody` : nullable pour permettre à l'utilisateur + * d'effacer le template (retomber sur le fallback hardcodé). */ export const updatePlanValidator = vine.create({ name: vine.string().minLength(1).maxLength(80).optional(), description: vine.string().maxLength(500).optional(), steps: vine.array(planStep).minLength(1).maxLength(10).optional(), + thanksSubject: vine.string().maxLength(200).nullable().optional(), + thanksBody: vine.string().maxLength(5000).nullable().optional(), }) /** @@ -33,4 +38,6 @@ export const createPlanValidator = vine.create({ name: vine.string().minLength(1).maxLength(80), description: vine.string().maxLength(500).optional(), steps: vine.array(planStep).minLength(1).maxLength(10), + thanksSubject: vine.string().maxLength(200).nullable().optional(), + thanksBody: vine.string().maxLength(5000).nullable().optional(), }) diff --git a/apps/api/config/queue.ts b/apps/api/config/queue.ts index e586687..502f0e1 100644 --- a/apps/api/config/queue.ts +++ b/apps/api/config/queue.ts @@ -18,7 +18,7 @@ export const redisConnection: RedisOptions = { * Liste des queues. La concurrence est appliquée côté worker. * Ajouter une queue ici → ajouter un Worker correspondant dans #start/queue.ts. */ -export const queueNames = ['ocr', 'relances', 'checkins', 'kpis'] as const +export const queueNames = ['ocr', 'relances', 'checkins', 'kpis', 'payment-thanks'] as const export type QueueName = (typeof queueNames)[number] export const queueConcurrency: Record = { @@ -26,4 +26,5 @@ export const queueConcurrency: Record = { relances: 5, checkins: 5, kpis: 1, + 'payment-thanks': 5, } diff --git a/apps/api/database/migrations/1778260000000_add_thanks_template_to_plans_table.ts b/apps/api/database/migrations/1778260000000_add_thanks_template_to_plans_table.ts new file mode 100644 index 0000000..d536851 --- /dev/null +++ b/apps/api/database/migrations/1778260000000_add_thanks_template_to_plans_table.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'plans' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + // Email de remerciement envoyé au client final dès que l'utilisateur + // confirme avoir été payé (via check-in ou mark-paid). Nullable parce + // que les plans custom existants n'auront rien tant que l'user n'a pas + // édité — le mail_dispatcher applique un fallback hardcodé. + table.text('thanks_subject').nullable() + table.text('thanks_body').nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('thanks_subject') + table.dropColumn('thanks_body') + }) + } +} diff --git a/apps/api/database/migrations/1778260000100_add_thanks_email_sent_to_activity_event_kind.ts b/apps/api/database/migrations/1778260000100_add_thanks_email_sent_to_activity_event_kind.ts new file mode 100644 index 0000000..7f979bc --- /dev/null +++ b/apps/api/database/migrations/1778260000100_add_thanks_email_sent_to_activity_event_kind.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Étend l'enum natif Postgres `activity_event_kind` avec la valeur + * `thanks_email_sent` (timeline de la facture lorsque l'email de + * remerciement automatique part au client après confirmation de paiement). + */ +export default class extends BaseSchema { + // Postgres interdit ALTER TYPE ... ADD VALUE à l'intérieur d'une transaction. + // On désactive le wrap transactionnel pour cette migration spécifiquement. + static disableTransactions = true + + async up() { + this.schema.raw("ALTER TYPE activity_event_kind ADD VALUE IF NOT EXISTS 'thanks_email_sent'") + } + + async down() { + // Postgres ne supporte pas le retrait d'une valeur d'enum sans recréer + // le type. On laisse la valeur en place côté DB — le code ne l'utilisera + // simplement plus si on rollback. Suffisant pour cette migration. + } +} diff --git a/apps/api/database/migrations/1778260000200_backfill_thanks_template_for_default_plans.ts b/apps/api/database/migrations/1778260000200_backfill_thanks_template_for_default_plans.ts new file mode 100644 index 0000000..c3236bc --- /dev/null +++ b/apps/api/database/migrations/1778260000200_backfill_thanks_template_for_default_plans.ts @@ -0,0 +1,61 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Backfill des templates de remerciement sur les plans pré-fournis qui + * existaient AVANT l'ajout des colonnes `thanks_subject` / `thanks_body`. + * + * `provisionDefaultPlans` est idempotent sur slug — il skip si le plan + * existe déjà — donc les orgs créées avant la migration `1778260000000_*` + * gardaient ces colonnes à NULL et l'éditeur de plan affichait du vide + * (le mailer tombait sur le fallback hardcodé, donc fonctionnellement OK, + * mais pas la promesse « éditable par plan »). + * + * On UPDATE seulement les rows avec thanks_subject IS NULL pour ne pas + * écraser les éventuelles éditions utilisateur. Les valeurs miroirent + * exactement DEFAULT_PLANS dans `app/services/default_plans.ts`. + */ + +const DEFAULTS: Array<{ slug: string; subject: string; body: string }> = [ + { + slug: 'standard-30j', + subject: 'Merci ! Bien reçu pour la facture {{numero}}', + body: + "Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement et au plaisir de continuer à travailler ensemble.\n\n{{signature}}", + }, + { + slug: 'rapide-15j', + subject: 'Paiement bien reçu — facture {{numero}}', + body: + 'Bonjour {{client.name}},\n\nMerci, nous avons bien reçu le règlement de la facture {{numero}} ({{amount}}). Bonne continuation.\n\n{{signature}}', + }, + { + slug: 'patient-60j', + subject: 'Merci — règlement bien reçu pour {{numero}}', + body: + "Bonjour {{client.name}},\n\nNous accusons bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci de votre confiance, à très bientôt.\n\n{{signature}}", + }, + { + slug: 'ferme-7j', + subject: 'Règlement reçu — facture {{numero}}', + body: + 'Bonjour {{client.name}},\n\nNous confirmons la bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci.\n\n{{signature}}', + }, +] + +export default class extends BaseSchema { + async up() { + for (const tpl of DEFAULTS) { + await this.db + .from('plans') + .where('slug', tpl.slug) + .whereNull('thanks_subject') + .update({ thanks_subject: tpl.subject, thanks_body: tpl.body }) + } + } + + async down() { + // No-op : on ne sait pas distinguer nos backfills d'éventuelles éditions + // utilisateur identiques. Les colonnes elles-mêmes sont droppées par la + // migration 1778260000000_add_thanks_template_to_plans_table down(). + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index b27e9c6..9c28f8a 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -17,7 +17,7 @@ export class ActivityEventSchema extends BaseModel { @column({ isPrimary: true }) declare id: string @column() - declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' + declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted' | 'thanks_email_sent' @column() declare label: string @column() @@ -286,7 +286,7 @@ export class PlanStepSchema extends BaseModel { } export class PlanSchema extends BaseModel { - static $columns = ['createdAt', 'description', 'id', 'isDefault', 'name', 'organizationId', 'slug', 'updatedAt'] as const + static $columns = ['createdAt', 'description', 'id', 'isDefault', 'name', 'organizationId', 'slug', 'thanksBody', 'thanksSubject', 'updatedAt'] as const $columns = PlanSchema.$columns @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @@ -302,6 +302,10 @@ export class PlanSchema extends BaseModel { declare organizationId: string @column() declare slug: string | null + @column() + declare thanksBody: string | null + @column() + declare thanksSubject: string | null @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null } @@ -404,7 +408,7 @@ export class RelanceTaskSchema extends BaseModel { } export class UserSchema extends BaseModel { - static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const + static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'isAdmin', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const $columns = UserSchema.$columns @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @@ -417,6 +421,8 @@ export class UserSchema extends BaseModel { @column({ isPrimary: true }) declare id: string @column() + declare isAdmin: boolean + @column() declare microsoftId: string | null @column() declare organizationId: string | null diff --git a/apps/api/start/queue.ts b/apps/api/start/queue.ts index 87abefb..a81284b 100644 --- a/apps/api/start/queue.ts +++ b/apps/api/start/queue.ts @@ -19,6 +19,7 @@ import logger from '@adonisjs/core/services/logger' import { registerWorker, shutdownQueue } from '#services/queue' import { sendRelanceJob } from '#jobs/send_relance_job' import { sendCheckinJob } from '#jobs/send_checkin_job' +import { sendPaymentThanksJob } from '#jobs/send_payment_thanks_job' if (app.getEnvironment() === 'web') { try { @@ -30,7 +31,11 @@ if (app.getEnvironment() === 'web') { await sendCheckinJob(job.data) }) - logger.info('BullMQ workers ready (relances, checkins)') + registerWorker<{ invoiceId: string }>('payment-thanks', async (job) => { + await sendPaymentThanksJob(job.data) + }) + + logger.info('BullMQ workers ready (relances, checkins, payment-thanks)') app.terminating(async () => { logger.info('shutting down BullMQ workers') diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx index 91961e7..a461260 100644 --- a/apps/web/src/components/dashboard/ActivityFeed.tsx +++ b/apps/web/src/components/dashboard/ActivityFeed.tsx @@ -1,6 +1,13 @@ import { format, parseISO } from "date-fns"; import { fr } from "date-fns/locale"; -import { Send, CheckCircle2, Inbox, AlertTriangle, type LucideIcon } from "lucide-react"; +import { + Send, + CheckCircle2, + Inbox, + AlertTriangle, + MailCheck, + type LucideIcon, +} from "lucide-react"; import { Card } from "@rubis/ui"; import { Eyebrow } from "@rubis/ui"; @@ -15,7 +22,8 @@ export type ActivityKind = | "relance_sent" | "invoice_paid" | "invoice_imported" - | "warning_drafted"; + | "warning_drafted" + | "thanks_email_sent"; export type ActivityEvent = { id: string; @@ -31,6 +39,7 @@ const ICONS: Record = { invoice_paid: CheckCircle2, invoice_imported: Inbox, warning_drafted: AlertTriangle, + thanks_email_sent: MailCheck, }; const TONE: Record = { @@ -38,6 +47,7 @@ const TONE: Record = { invoice_paid: "text-rubis-deep", invoice_imported: "text-ink-2", warning_drafted: "text-rubis-deep", + thanks_email_sent: "text-rubis", }; type ActivityFeedProps = { diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts index da7a0bf..c546340 100644 --- a/apps/web/src/mocks/db.ts +++ b/apps/web/src/mocks/db.ts @@ -306,7 +306,9 @@ export const mockDb = { updatePlan( orgId: string, id: string, - patch: Partial>, + patch: Partial< + Pick + >, ): Plan | undefined { const db = load(); const idx = db.plans.findIndex( diff --git a/apps/web/src/mocks/handlers/plans.ts b/apps/web/src/mocks/handlers/plans.ts index 0dc31a1..9834555 100644 --- a/apps/web/src/mocks/handlers/plans.ts +++ b/apps/web/src/mocks/handlers/plans.ts @@ -41,12 +41,16 @@ const updatePlanSchema = z.object({ name: z.string().min(1).max(80).optional(), description: z.string().max(500).optional(), steps: z.array(updatePlanStepSchema).min(1).max(10).optional(), + thanksSubject: z.string().max(200).nullable().optional(), + thanksBody: z.string().max(5000).nullable().optional(), }); const createPlanSchema = z.object({ name: z.string().min(1).max(80), description: z.string().max(500).optional(), steps: z.array(updatePlanStepSchema).min(1).max(10), + thanksSubject: z.string().max(200).nullable().optional(), + thanksBody: z.string().max(5000).nullable().optional(), }); const RESERVED_SLUGS = new Set(["nouveau", "new", "create"]); @@ -141,6 +145,8 @@ export const planHandlers = [ ...s, id: s.id ?? `step_${planId}_${idx}_${Date.now()}`, })), + thanksSubject: parsed.data.thanksSubject ?? null, + thanksBody: parsed.data.thanksBody ?? null, }); return HttpResponse.json({ data: created }, { status: 201 }); }), @@ -181,6 +187,12 @@ export const planHandlers = [ description: parsed.data.description, }), ...(steps !== undefined && { steps }), + ...(parsed.data.thanksSubject !== undefined && { + thanksSubject: parsed.data.thanksSubject, + }), + ...(parsed.data.thanksBody !== undefined && { + thanksBody: parsed.data.thanksBody, + }), }); return HttpResponse.json({ data: updated }); diff --git a/apps/web/src/mocks/seed.ts b/apps/web/src/mocks/seed.ts index 37829da..fa9822a 100644 --- a/apps/web/src/mocks/seed.ts +++ b/apps/web/src/mocks/seed.ts @@ -99,6 +99,9 @@ export const SEED_PLANS: Plan[] = [ name: "Standard B2B", description: "Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.", isDefault: true, + thanksSubject: "Merci ! Bien reçu pour la facture {{numero}}", + thanksBody: + "Bonjour {{client.name}},\n\nNous confirmons la bonne réception du règlement de la facture {{numero}} d'un montant de {{amount}}. Merci pour ce paiement et au plaisir de continuer à travailler ensemble.\n\n{{signature}}", steps: [ { id: "step_std_1", @@ -138,6 +141,9 @@ export const SEED_PLANS: Plan[] = [ name: "Rapide", description: "Cadence resserrée pour les factures récurrentes ou les délais courts.", isDefault: true, + thanksSubject: "Paiement bien reçu — facture {{numero}}", + thanksBody: + "Bonjour {{client.name}},\n\nMerci, nous avons bien reçu le règlement de la facture {{numero}} ({{amount}}). Bonne continuation.\n\n{{signature}}", steps: [ { id: "step_rap_1", @@ -177,6 +183,9 @@ export const SEED_PLANS: Plan[] = [ name: "Patient", description: "Pour les clients de longue date. On laisse respirer avant de relancer.", isDefault: true, + thanksSubject: "Merci — règlement bien reçu pour {{numero}}", + thanksBody: + "Bonjour {{client.name}},\n\nNous accusons bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci de votre confiance, à très bientôt.\n\n{{signature}}", steps: [ { id: "step_pat_1", @@ -207,6 +216,9 @@ export const SEED_PLANS: Plan[] = [ name: "Ferme", description: "Cadence stricte pour les clients à risque ou les retards récurrents.", isDefault: true, + thanksSubject: "Règlement reçu — facture {{numero}}", + thanksBody: + "Bonjour {{client.name}},\n\nNous confirmons la bonne réception du paiement de la facture {{numero}} ({{amount}}). Merci.\n\n{{signature}}", steps: [ { id: "step_fer_1", diff --git a/apps/web/src/routes/_app/plans_.$slug.tsx b/apps/web/src/routes/_app/plans_.$slug.tsx index b9bdf33..e7e2220 100644 --- a/apps/web/src/routes/_app/plans_.$slug.tsx +++ b/apps/web/src/routes/_app/plans_.$slug.tsx @@ -57,17 +57,31 @@ function PlanEditorPage() { const [selectedStepId, setSelectedStepId] = useState(null); const bodyRef = useRef(null); + // Email de remerciement (envoyé au client après confirmation de paiement). + // null = utiliser le fallback côté API. On hydrate depuis le serveur. + const [draftThanksSubject, setDraftThanksSubject] = useState(null); + const [draftThanksBody, setDraftThanksBody] = useState(null); + const thanksBodyRef = useRef(null); + // Sync : quand le plan arrive ou change côté serveur, on remet à zéro l'état // local. On évite les races avec une clé sur plan.id+updatedAt. useEffect(() => { if (!plan) return; setDraftSteps(plan.steps); setSelectedStepId((current) => current ?? plan.steps[0]?.id ?? null); + setDraftThanksSubject(plan.thanksSubject); + setDraftThanksBody(plan.thanksBody); }, [plan?.id, plan?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps + type SavePayload = { + steps: PlanStep[]; + thanksSubject: string | null; + thanksBody: string | null; + }; + const saveMutation = useMutation({ - mutationFn: (steps: PlanStep[]) => - api.patch(`/api/v1/plans/${slug}`, { steps }), + mutationFn: (payload: SavePayload) => + api.patch(`/api/v1/plans/${slug}`, payload), onSuccess: (saved) => { void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() }); void queryClient.setQueryData( @@ -130,8 +144,25 @@ function PlanEditorPage() { }); }; + const insertVariableInThanks = (token: string) => { + const ta = thanksBodyRef.current; + if (!ta) return; + const current = draftThanksBody ?? ""; + const start = ta.selectionStart ?? current.length; + const end = ta.selectionEnd ?? current.length; + const newBody = current.slice(0, start) + token + current.slice(end); + setDraftThanksBody(newBody); + requestAnimationFrame(() => { + ta.focus(); + const cursor = start + token.length; + ta.setSelectionRange(cursor, cursor); + }); + }; + const isDirty = - JSON.stringify(draftSteps) !== JSON.stringify(plan.steps); + JSON.stringify(draftSteps) !== JSON.stringify(plan.steps) || + draftThanksSubject !== plan.thanksSubject || + draftThanksBody !== plan.thanksBody; const mood = planMoodLabel({ steps: draftSteps }); return ( @@ -173,7 +204,13 @@ function PlanEditorPage() { size="sm" disabled={!isDirty} loading={saveMutation.isPending} - onClick={() => saveMutation.mutate(draftSteps)} + onClick={() => + saveMutation.mutate({ + steps: draftSteps, + thanksSubject: draftThanksSubject, + thanksBody: draftThanksBody, + }) + } > {isDirty ? "Enregistrer" : "Aucune modification"} @@ -301,6 +338,84 @@ function PlanEditorPage() { )} + + {/* === Email de remerciement (envoyé après confirmation de paiement) === */} +
+ Email de remerciement + +

+ Envoyé automatiquement à votre client dès que vous confirmez avoir + reçu le paiement (réponse « Oui, payé » au check-in ou bouton + « Marquer encaissée »). +

+ + + + setDraftThanksSubject( + e.target.value === "" ? null : e.target.value, + ) + } + placeholder="Merci ! Bien reçu pour la facture {{numero}}" + /> + + + +