karbe/src/lib/email.ts
Ubuntu 5be62f012f
All checks were successful
CI / test (pull_request) Successful in 2m40s
feat(rental): Sprint O — reversements prestataires (payouts)
Marketplace encaisse centralisé sur System D → besoin de tracer les
virements mensuels aux prestataires tiers. Migration appliquée prod.

Schema :
- Modèle RentalPayoutMark { id, providerId, periodMonth, amount,
  reference, paidAt, paidByEmail }. Unique (providerId, periodMonth)
  → 1 mark = 1 mois = 1 virement par provider.

Lib src/lib/payouts.ts :
- monthKey(d) → 1er du mois minuit UTC (clé de période).
- listProviderPayouts({monthsBack=6}) → grid provider × mois avec
  bookingsCount + grossAmount (itemsTotal) + commission + netAmount
  (gross-commission) + statut paid via RentalPayoutMark. Exclut
  System D (commission 0%, géré par l'asso). Statut « payé » lu
  depuis les marks. Tri : mois desc puis providerName.
- createPayoutMark (idempotent via findUnique avant insert) +
  deletePayoutMark.

Politique : net dû = itemsTotal - commissionAmount (depositTotal
hors flux, collecté par le provider auprès du client). Politique
documentée dans le commentaire en tête de payouts.ts.

/admin/payouts/page.tsx :
- 3 KPIs (À payer / Déjà payé / Mois affichés).
- Une section par mois (6 derniers), tableau provider × CA brut +
  commission + net dû + statut.
- MarkPaidForm : bouton « Marquer payé » → form inline (amount
  pré-rempli avec net dû, reference optionnelle) → action
  markPayoutPaidAction. Statut payé montre amount + ref + bouton
  « Annuler marquage ».

Server actions :
- markPayoutPaidAction (admin only, idempotent, audit
  admin.payouts/payout.mark + payout.already_marked) → envoie
  sendPayoutSent au contactEmail du provider (best-effort).
- unmarkPayoutPaidAction → delete + audit payout.unmark.

Email sendPayoutSent : notification au provider quand un virement est
marqué payé. Inclut amount + reference + lien dashboard.

Sidebar admin gagne entrée « Reversements » sous Activité.

Tests vitest tests/lib/payouts.test.ts (4 cas) : monthKey
normalisation UTC + idempotence + janvier sans bug, formatMonth fr-FR.
Total : 74/74 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:59:16 +00:00

461 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Service email — Resend si `RESEND_API_KEY` est configuré, sinon log console.
*
* Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec
* d'envoi (best-effort, le booking est l'action principale).
*/
import "server-only";
let resendClient: import("resend").Resend | null | undefined;
async function getResend(): Promise<import("resend").Resend | null> {
if (resendClient !== undefined) return resendClient;
const key = process.env.RESEND_API_KEY?.trim();
if (!key) {
resendClient = null;
return null;
}
try {
const { Resend } = await import("resend");
resendClient = new Resend(key);
return resendClient;
} catch (e) {
console.error("[email] resend init failed:", e instanceof Error ? e.message : e);
resendClient = null;
return null;
}
}
export type EmailOpts = {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
};
const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé <no-reply@karbe.cosmolan.fr>";
export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> {
const client = await getResend();
if (!client) {
console.log(
"[email] dry-run (no RESEND_API_KEY):",
JSON.stringify({ to: opts.to, subject: opts.subject }),
);
return { ok: true, reason: "dry-run" };
}
try {
const { data, error } = await client.emails.send({
from: DEFAULT_FROM,
to: Array.isArray(opts.to) ? opts.to : [opts.to],
subject: opts.subject,
html: opts.html,
text: opts.text,
replyTo: opts.replyTo,
});
if (error) {
console.error("[email] resend error:", error);
return { ok: false, reason: error.message };
}
return { ok: true, id: data?.id };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[email] send failed:", msg);
return { ok: false, reason: msg };
}
}
// ---------- Templates ----------
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
const baseStyle = `
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #18181b;
max-width: 580px;
margin: 0 auto;
padding: 24px;
line-height: 1.5;
`;
function wrap(title: string, content: string): string {
return `<!doctype html><html><body style="background:#fafafa;margin:0;padding:24px 12px;">
<div style="${baseStyle}background:white;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<h1 style="margin:0 0 16px;font-size:22px;font-weight:600;color:#18181b;">${title}</h1>
${content}
<hr style="margin:24px 0;border:0;border-top:1px solid #e4e4e7;" />
<p style="font-size:11px;color:#71717a;margin:0;">
Karbé · <a href="${SITE_URL}" style="color:#71717a;">${SITE_URL}</a><br/>
Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
</p>
</div>
</body></html>`;
}
export async function sendSignupWelcome(to: string, firstName: string): Promise<void> {
await sendEmail({
to,
subject: "Bienvenue sur Karbé",
html: wrap(
`Bienvenue ${firstName} !`,
`<p>Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.</p>
<p><a href="${SITE_URL}/carbets" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Découvrir les carbets</a></p>`,
),
text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`,
});
}
export async function sendBookingRequestToTenant(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
endDate: Date,
amount: string,
currency: string,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Demande de réservation enregistrée — ${carbetTitle}`,
html: wrap(
"Demande de réservation envoyée",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de réservation pour <strong>${carbetTitle}</strong> a bien été enregistrée :</p>
<ul>
<li>Arrivée : ${fmt(startDate)}</li>
<li>Départ : ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p>Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
),
});
}
export async function sendBookingRequestToOwner(
to: string,
ownerFirstName: string,
bookingId: string,
carbetTitle: string,
tenantName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Nouvelle demande de réservation — ${carbetTitle}`,
html: wrap(
"Nouvelle demande à confirmer",
`<p>Bonjour ${ownerFirstName},</p>
<p><strong>${tenantName}</strong> souhaite réserver <strong>${carbetTitle}</strong> :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p>Connectez-vous à votre espace hôte pour confirmer ou refuser.</p>
<p><a href="${SITE_URL}/espace-hote" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mon espace hôte</a></p>`,
),
});
}
export async function sendBookingConfirmed(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Réservation confirmée — ${carbetTitle}`,
html: wrap(
"Votre séjour est confirmé",
`<p>Bonjour ${firstName},</p>
<p>Votre réservation pour <strong>${carbetTitle}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
),
});
}
export async function sendNewCeRequest(
orgName: string,
managerEmail: string,
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
await sendEmail({
to: adminEmail,
subject: `Nouvelle demande CE — ${orgName}`,
html: wrap(
"Demande de Comité d'Entreprise à valider",
`<p>Une organisation vient de s'inscrire en tant que Comité d'Entreprise.</p>
<ul>
<li>Nom : <strong>${orgName}</strong></li>
<li>Email du manager : ${managerEmail}</li>
</ul>
<p><a href="${SITE_URL}/admin/organizations?status=pending" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
<p style="font-size:12px;color:#71717a;">Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que <code>Organization.approved=false</code>.</p>`,
),
});
}
export async function sendCeInviteEmail(
to: string,
orgName: string,
inviteUrl: string,
inviterName?: string | null,
): Promise<void> {
const intro = inviterName
? `<strong>${inviterName}</strong> vous invite à rejoindre`
: "Vous êtes invité à rejoindre";
await sendEmail({
to,
subject: `Invitation à rejoindre « ${orgName} » sur Karbé`,
html: wrap(
`Invitation Karbé — ${orgName}`,
`<p>${intro} le Comité d'Entreprise <strong>${orgName}</strong> sur Karbé.</p>
<p>Cliquez sur le bouton ci-dessous pour créer votre compte CE_MEMBER et accéder aux carbets et matériel de votre CE :</p>
<p><a href="${inviteUrl}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Rejoindre ${orgName}</a></p>
<p style="font-size:12px;color:#71717a;">Lien valable 14 jours. Si vous n'êtes pas le destinataire attendu, ignorez cet email.</p>
<p style="font-size:11px;color:#a1a1aa;word-break:break-all;">Lien direct : ${inviteUrl}</p>`,
),
text: `${intro.replace(/<[^>]+>/g, "")} le CE ${orgName} sur Karbé : ${inviteUrl}`,
});
}
export async function sendCeApproved(
to: string,
firstName: string,
orgName: string,
): Promise<void> {
await sendEmail({
to,
subject: `Votre CE « ${orgName} » est validé sur Karbé`,
html: wrap(
"Organisation validée",
`<p>Bonjour ${firstName},</p>
<p>Votre Comité d'Entreprise <strong>${orgName}</strong> vient d'être validé. Vous pouvez désormais publier vos carbets et activer la location de matériel pour vos membres et le public touriste.</p>
<p><a href="${SITE_URL}/espace-ce" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Accéder à mon espace CE</a></p>`,
),
});
}
export async function sendNewRentalProviderRequest(
providerName: string,
userEmail: string,
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
await sendEmail({
to: adminEmail,
subject: `Nouvelle demande prestataire matériel — ${providerName}`,
html: wrap(
"Demande de prestataire à valider",
`<p>Une demande d'inscription en tant que prestataire de location matériel vient d'arriver.</p>
<ul>
<li>Nom : <strong>${providerName}</strong></li>
<li>Email contact : ${userEmail}</li>
</ul>
<p><a href="${SITE_URL}/admin/rental-providers" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
<p style="font-size:12px;color:#71717a;">Le prestataire reste en attente jusqu'à validation. Ses items ne sont pas publiés tant que <code>approved=false</code>.</p>`,
),
});
}
export async function sendPasswordReset(
to: string,
resetUrl: string,
): Promise<void> {
await sendEmail({
to,
subject: "Réinitialisation de votre mot de passe Karbé",
html: wrap(
"Réinitialiser votre mot de passe",
`<p>Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :</p>
<p><a href="${resetUrl}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Réinitialiser mon mot de passe</a></p>
<p style="font-size:12px;color:#71717a;">Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.</p>`,
),
text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
});
}
type RentalLineSummary = { qty: number; itemName: string };
function renderLines(lines: RentalLineSummary[]): string {
return lines.map((l) => `<li>${l.qty}× ${l.itemName}</li>`).join("");
}
export async function sendRentalRequestedTenant(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
amount: string,
currency: string,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Demande de location matériel — ${providerName}`,
html: wrap(
"Votre demande de location est enregistrée",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de location auprès de <strong>${providerName}</strong> est bien enregistrée :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p><strong>Matériel demandé :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalRequestedProvider(
to: string,
providerName: string,
rentalBookingId: string,
tenantName: string,
startDate: Date,
endDate: Date,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Nouvelle demande de location — ${tenantName}`,
html: wrap(
"Nouvelle demande à préparer",
`<p>Bonjour ${providerName},</p>
<p><strong>${tenantName}</strong> vient de réserver du matériel :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p><strong>Matériel :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.</p>
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes réservations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalCancelled(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
refundAmount: string,
currency: string,
policyLabel: string,
cancelledBy: "tenant" | "provider" | "admin",
): Promise<void> {
const actor =
cancelledBy === "tenant"
? "Vous avez annulé"
: cancelledBy === "provider"
? `${providerName} a annulé`
: "L'équipe Karbé a annulé";
await sendEmail({
to,
subject: `Location annulée — ${providerName}`,
html: wrap(
"Location annulée",
`<p>Bonjour ${firstName},</p>
<p>${actor} votre location auprès de <strong>${providerName}</strong>.</p>
<p><strong>Politique appliquée :</strong> ${policyLabel}</p>
<p><strong>Remboursement :</strong> ${Number(refundAmount).toFixed(2)} ${currency}</p>
<p>Si un paiement avait été reçu, le remboursement est traité par Stripe sous 3-5 jours ouvrés.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalConfirmed(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Location confirmée — ${providerName}`,
html: wrap(
"Votre location est confirmée",
`<p>Bonjour ${firstName},</p>
<p>Le paiement de votre location auprès de <strong>${providerName}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est validé.</p>
<p>Le prestataire vous contactera pour organiser la remise du matériel sur place.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma location</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendPayoutSent(
to: string,
providerName: string,
periodMonth: Date,
amount: string,
reference: string | null,
): Promise<void> {
const monthLabel = periodMonth.toLocaleDateString("fr-FR", {
timeZone: "UTC",
year: "numeric",
month: "long",
});
await sendEmail({
to,
subject: `Reversement Karbé — ${monthLabel}`,
html: wrap(
`Reversement ${monthLabel}`,
`<p>Bonjour ${providerName},</p>
<p>Le reversement de vos locations matériel pour <strong>${monthLabel}</strong> a été effectué :</p>
<ul>
<li>Montant : <strong>${Number(amount).toFixed(2)} EUR</strong></li>
${reference ? `<li>Référence virement : <code>${reference}</code></li>` : ""}
</ul>
<p>Vérifiez votre compte bancaire dans les 1 à 3 jours ouvrés. En cas de question, répondez à cet email.</p>
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir mes réservations</a></p>`,
),
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
amount: string,
currency: string,
): Promise<void> {
await sendEmail({
to,
subject: `Remboursement traité — ${carbetTitle}`,
html: wrap(
"Remboursement en cours",
`<p>Bonjour ${firstName},</p>
<p>Votre réservation pour <strong>${carbetTitle}</strong> a été annulée et le remboursement de <strong>${Number(amount).toFixed(2)} ${currency}</strong> est en cours de traitement par Stripe (3 à 5 jours ouvrés).</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="color:#18181b;">Détails de la réservation</a></p>`,
),
});
}