Première moitié de la feature marque blanche : la machinerie complète qui permet à un compte Business d'envoyer ses emails de relance avec son propre logo, ses propres couleurs et son nom comme expéditeur, à la place du branding Rubis. Architecture : - Nouvelle colonne JSONB `organizations.brand_settings` (12 tokens customisables : logo, senderName, et 10 couleurs — primary, banner, body bg, card bg, text, text muted, border, link, button text). Null = palette Rubis intacte. Validation hex stricte (#RRGGBB). - Service `#services/brand` avec `resolveBrandTokens(org)` qui merge defaults + overrides en respectant le plan (couleurs/logo = Business only ; senderName = cascade pour tous les plans). Mergeurs avec sémantique "null = reset au default sur ce champ précis" pour les patches partiels. - Service mutualisé `#services/media_storage` qui remplace l'ancien `blog_uploads.ts`. Scopes `blog` (4 MB, jpg/png/webp) et `brand-logo` (1 MB, + svg accepté). Cleanup automatique du logo précédent lors d'un remplacement (pas de versioning — la conv produit est "on écrase"). - Controller `BrandController` (5 endpoints) + middleware `AssertBusinessPlanMiddleware` qui throw 403 `business_plan_required` (code matché par le SPA pour l'upsell card). - Refactor des 3 templates mail (relance, payment thanks, checkin) + layout commun pour accepter `tokens: BrandTokens` en prop. Le dispatcher résout les tokens per-org pour relance + remerciement (= user → client, branded), et passe `DEFAULT_BRAND` au checkin (= Rubis → user, toujours Rubis-branded). - Routes publiques pour le logo : `/api/v1/uploads/brand-logos/:filename` (sans auth, cache immutable, X-Content-Type-Options: nosniff pour les SVG). UI self-service arrive dans la prochaine version (v1.12.0). En attendant, un compte Business peut être configuré via Bruno / API directe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
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<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
|
|
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
|
|
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
|
|
organization?: Pick<Organization, 'name'> | 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<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')
|
|
// 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
|
|
}
|
|
}
|