import mail from '@adonisjs/mail/services/main' import env from '#start/env' import { DateTime } from 'luxon' 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 PlanStep from '#models/plan_step' import type User from '#models/user' import type Organization from '#models/organization' 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-sur-l-ongle.fr') const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle") // 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) return // demo : ne pas envoyer pour de vrai const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp')) await mailer.send((m) => { m.from(fromAddress, fromName) .to(client.email, client.name) .subject(subject) // Texte brut pour V1 — on ajoutera un template HTML quand on aura // décidé d'un look graphique pour les relances. .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) } }) } 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-sur-l-ongle.fr') const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle") // 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) .text(body) }) }