This commit is contained in:
139
src/lib/email.ts
139
src/lib/email.ts
@@ -1321,3 +1321,142 @@ export async function sendCreditNoteEmail(params: {
|
||||
console.error("Failed to send credit note email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 9b-2 — auto-charge failure notice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sent when an off-session auto-charge attempt fails for an issued
|
||||
* invoice (card declined, expired, 3DS required, etc.). Customer
|
||||
* receives this in their billing-snapshot locale. Contains:
|
||||
* - Invoice number + amount + due date
|
||||
* - Failure reason (a short human-readable string from Stripe)
|
||||
* - Manual-pay link to /billing/<invoiceNumber> where they can
|
||||
* run the regular Pay-by-Card flow (which uses
|
||||
* setup_future_usage to also refresh the saved card)
|
||||
*
|
||||
* Critical: the failure reason from Stripe can contain sensitive
|
||||
* details (card BIN, country, etc.). We pass a sanitized short
|
||||
* string from the caller — never the full raw error.
|
||||
*/
|
||||
export async function sendAutoChargeFailedEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
companyName: string;
|
||||
invoiceNumber: string;
|
||||
totalChf: number;
|
||||
currency: string;
|
||||
dueAt: string;
|
||||
/**
|
||||
* Short, customer-safe reason. e.g. "Your card was declined."
|
||||
* or "Your card has expired." Caller maps Stripe error codes to
|
||||
* these strings; we never pass raw API error messages.
|
||||
*/
|
||||
reasonForCustomer: string;
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
const L = params.locale;
|
||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||
const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||
|
||||
const subjectsByLocale: Record<typeof L, string> = {
|
||||
en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`,
|
||||
de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`,
|
||||
fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`,
|
||||
it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`,
|
||||
};
|
||||
const greetingsByLocale: Record<typeof L, string> = {
|
||||
en: `Hello ${params.contactName},`,
|
||||
de: `Sehr geehrte/r ${params.contactName},`,
|
||||
fr: `Bonjour ${params.contactName},`,
|
||||
it: `Gentile ${params.contactName},`,
|
||||
};
|
||||
const introByLocale: Record<typeof L, string> = {
|
||||
en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`,
|
||||
de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`,
|
||||
fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`,
|
||||
it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`,
|
||||
};
|
||||
const reasonLabel: Record<typeof L, string> = {
|
||||
en: "Reason given by the card network",
|
||||
de: "Vom Kartennetzwerk gemeldeter Grund",
|
||||
fr: "Motif communiqué par le réseau de carte",
|
||||
it: "Motivo comunicato dal circuito",
|
||||
};
|
||||
const actionLineByLocale: Record<typeof L, string> = {
|
||||
en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`,
|
||||
de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`,
|
||||
fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`,
|
||||
it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`,
|
||||
};
|
||||
const labels: Record<typeof L, Record<string, string>> = {
|
||||
en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" },
|
||||
de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||
fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" },
|
||||
it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||
};
|
||||
const l = labels[L];
|
||||
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||
const safeReason = escapeHtml(params.reasonForCustomer);
|
||||
const safeIntro = escapeHtml(introByLocale[L]);
|
||||
const safeAction = escapeHtml(actionLineByLocale[L]);
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: subjectsByLocale[L],
|
||||
text: [
|
||||
greetingsByLocale[L],
|
||||
"",
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${l.number}: ${params.invoiceNumber}`,
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
"",
|
||||
`${reasonLabel[L]}: ${params.reasonForCustomer}`,
|
||||
"",
|
||||
actionLineByLocale[L],
|
||||
"",
|
||||
`${l.cta}:`,
|
||||
link,
|
||||
"",
|
||||
`${l.signoff},`,
|
||||
l.brand,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||
<h2 style="margin: 0 0 16px; color: #f59e0b;">${escapeHtml(subjectsByLocale[L])}</h2>
|
||||
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||
<p>${safeIntro}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#f59e0b; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
</table>
|
||||
<div style="background:#2a2a2a; border-left:3px solid #f59e0b; padding:10px 12px; margin:16px 0; font-size:13px;">
|
||||
<strong>${escapeHtml(reasonLabel[L])}:</strong> ${safeReason}
|
||||
</div>
|
||||
<p style="font-size:14px;">${safeAction}</p>
|
||||
<p>
|
||||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
${l.cta}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#888; font-size:12px; margin-top:24px;">
|
||||
${l.signoff},<br />${l.brand}
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send auto-charge-failed email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user