rubis/apps/api/app/services/mail_dispatcher.ts
ordinarthur 51217175ad
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
feat(banking): intégration Powens AISP + auto-réconciliation factures
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).

Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
  webview URL, list/get/delete connections, accounts, transactions,
  vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
  encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
  (TTL 10 min), handleCallback (upsert connection + accounts +
  fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
  Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
  + label normalisé (numero ou nom client, NFD strip + alphanum).
  Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
  HIGH → invoice.status=paid + bonus rubis + cancel relances +
  enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
  (user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
  powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
  USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
  / SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
  puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
  + colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
  sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
  récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
  (notif user après match auto, lien direct facture + libellé
  bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
  jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
  reconnecter la banque) et banking:simulate-payment (injecte une
  bank_transaction synthétique qui matche une facture, pour test E2E
  sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
  credentials Powens. Endpoint public GET /banking/status renvoie
  { enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
  rapporte désormais la vraie colonne en faute (numero/slug/…) avec
  message lisible « Le numéro de facture "F2026-0013" existe déjà »,
  au lieu d'un message ambigu sur organization_id.

Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
  switch + plan Pro/Business. Si Free → upsell card avec CTA vers
  /parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
  une banque". Si banque connectée → carte avec accounts (IBAN
  masqué FR76 **** **** **** 1234), solde, last sync, bouton
  Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
  avec badge ✓ animé + halo glow rubis, récap des comptes
  synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
  note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
  { enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
  useUpdateBankingSettings.

Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
  BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
  POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
  POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
  POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
  via kubectl patch, à remplacer post-KYC).

Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
  clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
  (anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
  POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
  powens_account_id), (account, powens_id) → rejouer un event ou un
  callback ne pose pas de problème.

Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
  Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
  prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
  flip flag → smoke test), snippet kubectl patch pour rotation
  ciblée de secrets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:03:32 +02:00

618 lines
19 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 { BankConnectedEmail } from '#mails/bank_connected_email'
import { InvoiceAutoPaidNotificationEmail } from '#mails/invoice_auto_paid_notification_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
}
}
type BankConnectedPayload = {
user: User
bank: { name: string }
accounts: Array<{ name: string; ibanMasked: string | null }>
}
/**
* Envoie un email de confirmation À L'UTILISATEUR (pas au client final)
* quand sa banque vient d'être connectée via Powens. Notif interne
* Rubis → user, donc toujours en branding Rubis (pas de marque blanche
* même pour Business).
*
* Idempotence : appelée en fire-and-forget depuis BankingService.handleCallback.
* Si l'envoi échoue, on log et on swallow — la connexion bancaire est déjà
* en DB, on ne veut pas casser le flow utilisateur pour un mail loupé.
*/
export async function sendBankConnectedEmail({
user,
bank,
accounts,
}: BankConnectedPayload) {
const subject = `${bank.name} est connectée à Rubis`
const accountsList = accounts
.map((a) => `${a.name}${a.ibanMasked ? ` (${a.ibanMasked})` : ''}`)
.join('\n')
const body = `Bonjour ${user.fullName ?? ''},
Bonne nouvelle : ${bank.name} est désormais reliée à votre espace Rubis.
Comptes synchronisés :
${accountsList}
Rubis va maintenant détecter automatiquement vos virements entrants
et arrêter les relances dès qu'une facture est payée.
Voir mes paramètres : ${env.get('WEB_URL', 'http://localhost:5173')}/parametres
Lecture seule. Aucun déplacement de fonds possible. Vous pouvez
déconnecter cette banque à tout moment depuis vos paramètres.
— L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const settingsUrl = `${env.get('WEB_URL', 'http://localhost:5173')}/parametres`
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
BankConnectedEmail({
tokens: DEFAULT_BRAND, // notif Rubis → user, jamais en marque blanche
user: { fullName: user.fullName ?? null },
bank,
accounts,
settingsUrl,
landingUrl,
})
)
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{
userId: user.id,
to: user.email,
bank: bank.name,
accountsCount: accounts.length,
driver,
},
'sendBankConnectedEmail: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.html(htmlBody)
.text(body)
})
logger.info(
{ userId: user.id, bank: bank.name, driver },
'sendBankConnectedEmail: send OK'
)
} catch (err) {
logger.error(
{ err, userId: user.id, bank: bank.name, driver },
'sendBankConnectedEmail: échec envoi'
)
throw err
}
}
type InvoiceAutoPaidNotifPayload = {
user: User
invoice: Invoice
client: Client
bankLabel: string
bankName: string
}
/**
* Notif À L'UTILISATEUR (pas au client) quand la réconciliation auto
* a marqué une facture payée à partir d'un virement bancaire détecté.
* Toujours en branding Rubis. Inclut le lien direct vers la facture.
*
* À distinguer de `sendPaymentThanksEmail` qui, lui, va au CLIENT pour
* le remercier.
*/
export async function sendInvoiceAutoPaidNotification({
user,
invoice,
client,
bankLabel,
bankName,
}: InvoiceAutoPaidNotifPayload) {
const amountFormatted = formatAmountFr(invoice.amountTtcCents)
const subject = `${client.name} a payé la facture ${invoice.numero}`
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const detailUrl = `${webUrl}/factures/${invoice.id}`
const body = `Bonjour ${user.fullName ?? ''},
${client.name} vient de régler la facture ${invoice.numero} d'un montant de ${amountFormatted}.
Rubis a détecté le virement entrant sur votre ${bankName} et a tout géré pour vous :
• Facture marquée payée
• Relances futures annulées
• Email de remerciement envoyé à ${client.name}
Libellé bancaire détecté :
${bankLabel}
Voir la facture : ${detailUrl}
Mode de réconciliation : automatique. Pour repasser en validation manuelle,
rendez-vous dans Paramètres → Banque.
— L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
InvoiceAutoPaidNotificationEmail({
tokens: DEFAULT_BRAND,
user: { fullName: user.fullName ?? null },
invoice: { numero: invoice.numero, amountFormatted, detailUrl },
client: { name: client.name },
bankLabel,
bankName,
landingUrl,
})
)
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{ userId: user.id, invoiceId: invoice.id, numero: invoice.numero, driver },
'sendInvoiceAutoPaidNotification: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.html(htmlBody)
.text(body)
})
logger.info(
{ userId: user.id, invoiceId: invoice.id, driver },
'sendInvoiceAutoPaidNotification: send OK'
)
} catch (err) {
logger.error(
{ err, userId: user.id, invoiceId: invoice.id, driver },
'sendInvoiceAutoPaidNotification: échec envoi'
)
throw err
}
}