import mail from '@adonisjs/mail/services/main' import logger from '@adonisjs/core/services/logger' import env from '#start/env' import { DateTime } from 'luxon' import { render } from '@react-email/components' import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template' 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' import { resolveBrandTokens, DEFAULT_BRAND } from '#services/brand' /** * 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 client: Client step: PlanStep user: User | null organization?: Organization | null } /** * Construit l'objet `vars` interpolé dans subject/body. Exposé pour * permettre la preview côté contrôleur (wizard de création de plan) * avec les mêmes variables que ce qui sera réellement envoyé. * * Variables disponibles : * - {{client.name}}, {{client.email}} * - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides) * - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}} * - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance) * - {{user.fullName}}, {{user.companyName}} * - {{signature}} */ export function buildRelanceVars({ invoice, client, user, organization, now = DateTime.utc(), }: { invoice: Pick client: Pick user: Pick | null organization?: Pick | null /** `now` injecté pour respecter virtualNow en mode démo. */ now?: DateTime }) { const dueDate = invoice.dueDate.toJSDate() // Jours de retard arrondis à l'entier — démo-aware via `now` injecté. const daysLate = Math.floor( now.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days ) return { client: { name: client.name, email: client.email, contactFirstName: client.contactFirstName ?? '', contactLastName: client.contactLastName ?? '', }, user: { fullName: user?.fullName ?? '', companyName: organization?.name ?? '', }, numero: invoice.numero, amount: formatAmountFr(invoice.amountTtcCents), dueDate: formatDateFr(dueDate), issueDate: formatDateFr(invoice.issueDate.toJSDate()), daysLate: String(daysLate), signature: user?.signature ?? user?.fullName ?? '', } } /** * Envoie un email de relance à un client à partir d'un step. * Le subject/body du step contiennent des placeholders Mustache-like * qu'on interpole avant l'envoi (cf. `buildRelanceVars`). * * Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev, * `resend` en prod). */ export async function sendRelanceEmail({ invoice, client, step, user, organization, }: RelancePayload) { const vars = buildRelanceVars({ invoice, client, user, organization, now: await clock.now(invoice.organizationId), }) const subject = renderTemplate(step.subject, vars) const body = renderTemplate(step.body, vars) const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro') // Le client final connaît l'org du user, pas Rubis. Le display From: vient // de `tokens.senderName` qui résout la cascade : // brandSettings.senderName (Business) → org.name → user.fullName → "Rubis". // Les couleurs/logo du template viennent aussi de tokens (Business only, // sinon palette Rubis intacte). L'adresse technique reste sur notre domaine // vérifié (SPF/DKIM Resend) en V1 — le send-on-behalf via Gmail/Microsoft/SMTP // arrive Phase 2/3/4 (cf. ADR à venir). const tokens = resolveBrandTokens(organization ?? null) const fromName = tokens.senderName || user?.fullName?.trim() || 'Rubis sur l\'ongle' // Calcule daysLate pour le récap visuel dans le HTML. const nowOrg = await clock.now(invoice.organizationId) const daysLate = Math.floor( nowOrg.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days ) const landingUrl = env.get('LANDING_URL', 'https://rubis.pro') // Rendu HTML via React Email — tokens dynamiques (Business custom ou Rubis). const htmlBody = await render( RelanceEmail({ tokens, invoice: { numero: invoice.numero, amountFormatted: formatAmountFr(invoice.amountTtcCents), dueDateFormatted: formatDateFr(invoice.dueDate.toJSDate()), daysLate, }, bodyText: body, landingUrl, }) ) // FORK DÉMO — unique point où l'app dévie de la prod. Si l'org est // en mode démo, on capture l'email dans demo_captured_emails au lieu // de l'envoyer via Resend. Tout le reste du pipeline (idempotence, // status update, rubis bump) tourne identique. const captured = await captureEmailIfDemo({ organizationId: invoice.organizationId, kind: 'relance', 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, stepOrder: step.order }, }) if (captured) { logger.info( { invoiceId: invoice.id, numero: invoice.numero, to: client.email }, 'sendRelanceEmail: capturé en mode démo (pas d\'envoi réel)' ) return // demo : ne pas envoyer pour de vrai } 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), }, 'sendRelanceEmail: 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 rendu depuis le composant React Email (DA Rubis). .html(htmlBody) // Plain text fallback : améliore la délivrabilité (anti-spam) et // sert pour les clients qui désactivent le HTML. .text(body) // Reply-To pointe sur l'utilisateur Rubis : si le client final répond // à la relance, sa réponse arrive chez le patron de la TPE, pas dans // notre boîte transactionnelle. if (user?.email) { m.replyTo(user.email, user.fullName ?? user.email) } }) logger.info( { invoiceId: invoice.id, numero: invoice.numero, driver }, 'sendRelanceEmail: send OK' ) } catch (err) { logger.error( { err, invoiceId: invoice.id, numero: invoice.numero, driver }, 'sendRelanceEmail: échec envoi' ) throw err } } type CheckinPayload = { invoice: Invoice client: Client user: User paidUrl: string pendingUrl: string } /** * Envoie le check-in à l'**utilisateur** (pas au client). Lui demande * si la facture a été payée, avec 2 liens publics qui modifient l'état * côté API et redirigent ensuite vers le SPA. * * Texte brut V1. Un template HTML viendra quand on aura figé le look * graphique (cf. ADR-021). */ export async function sendCheckinEmail({ invoice, client, user, paidUrl, pendingUrl, }: CheckinPayload) { const subject = `Facture ${invoice.numero} — payée par ${client.name} ?` const body = `Bonjour ${user.fullName ?? ''}, La facture ${invoice.numero} (${formatAmountFr(invoice.amountTtcCents)}) émise pour ${client.name} arrive à échéance aujourd'hui (${formatDateFr(invoice.dueDate.toJSDate())}). Avant que Rubis n'envoie la première relance, dites-nous où vous en êtes : ✓ J'ai été payé(e), pas besoin de relancer : ${paidUrl} → Toujours en attente, lance la relance comme prévu : ${pendingUrl} Ces liens expirent dans 24h. Merci, L'équipe Rubis` const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro') // Le check-in vient FROM Rubis (notification interne à l'user, pas au // client final). On garde donc le brand "Rubis sur l'ongle" comme display, // PAS le nom de l'org. const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle") // Rendu HTML — DA Rubis avec 2 boutons CTA Oui/Non. Toujours en branding // Rubis (jamais customisable) : c'est une notif Rubis → user, pas user → // client. Le user reconnaît Rubis comme expéditeur, pas sa propre marque. const landingUrl = env.get('LANDING_URL', 'https://rubis.pro') const htmlBody = await render( CheckinEmail({ tokens: DEFAULT_BRAND, invoice: { numero: invoice.numero, amountFormatted: formatAmountFr(invoice.amountTtcCents), dueDateFormatted: formatDateFr(invoice.dueDate.toJSDate()), }, client: { name: client.name }, user: { fullName: user.fullName ?? null }, paidUrl, pendingUrl, landingUrl, }) ) // FORK DÉMO — capture si demoMode (cf. sendRelanceEmail). const captured = await captureEmailIfDemo({ organizationId: invoice.organizationId, kind: 'checkin', to: { email: user.email, name: user.fullName ?? user.email }, from: { email: fromAddress, name: fromName }, replyTo: null, subject, body, meta: { invoiceId: invoice.id, clientId: client.id, paidUrl, pendingUrl }, }) if (captured) return const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp')) await mailer.send((m) => { m.from(fromAddress, fromName) .to(user.email, user.fullName ?? user.email) .subject(subject) // HTML rendu via React Email (DA Rubis), texte brut en fallback. .html(htmlBody) .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') // Tokens résolus + senderName aligné avec sendRelanceEmail. const tokens = resolveBrandTokens(organization ?? null) const fromName = tokens.senderName || user?.fullName?.trim() || 'Rubis sur l\'ongle' const landingUrl = env.get('LANDING_URL', 'https://rubis.pro') const htmlBody = await render( PaymentThanksEmail({ tokens, 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 } }